crates/estate-clock/src/lib.rs
//! # estate-clock — the estate's wall clock
//!
//! Where "now" comes from, how sources are reconciled, how the clock is moved,
//! and what the last attempt concluded. **Moved AS IS out of gunnar's
//! `gunnar-clock` crate** (gunnar gunnar-live 50c3e69, 2026-09-28) when holger's
//! estate needed the same clock with no dependency on gunnar (owner 2026-09-27:
//! "IF YOU CAN FIND COMMON CODE MOVE IT TO EDDA"). gunnar's names removed from
//! every identifier and from the wire — the magic is `ESTATE-TIME/1`, the SSHSIG
//! namespace `estate-time-v1@nordisk`, the variables `ESTATE_CLOCK*`, the binary
//! `estate-clock`; the prose below keeps gunnar where it records what was
//! measured on gunnar's estate, because that is where it was measured. The
//! [`offer`] module adds, also as is, what gunnar-front and gunnar-server did
//! around this crate that is no product's. gunnar keeps its own copy until it
//! adopts this one. **While it moves** (lane G3b, 2026-10-21) this crate also
//! speaks gunnar-clock's wire, `GUNNAR-TIME/1` in `gunnar-time-v1@gunnar.rs`
//! ([`peer::Dialect`]), and reads `GUNNAR_CLOCK*` as an alias of each
//! `ESTATE_CLOCK*` ([`config::var`]), so a Gunnar estate switches over two
//! releases with no flag day; both are dropped after the move.
//!
//! # Why this is its own crate
//!
//! Two machines must agree on the time, and they must agree by the same rules.
//! Every credential the appliance checks carries the FRONT's `issued_at_ms`
//! (gunnar-ui and monetize are the control plane's only clients, and both run
//! on the front), and `gunnar_grpc::auth::MAX_CLOCK_SKEW_MS` is five minutes.
//! So the invariant is: **the front has correct time, and the appliance follows
//! the front.** The front runs the `estate-clock` binary from this crate; the
//! appliance's PID 1 (`gunnar-server`) links the same library. One quorum rule,
//! one sanity window, one step-or-slew policy — a second implementation would be
//! a second opinion about what "now" is, and two opinions is the bug.
//!
//! # What was measured (2026-09-14) and what it changed
//!
//! The first cut of this code (gunnar `5792dda`, then `gunnar-server/src/clock.rs`)
//! asked `api.stripe.com`, `www.cloudflare.com` and `www.google.com` for an HTTPS
//! `Date` at boot. It was re-imaged onto the appliance and **did nothing**: the
//! box still answered `the credential is 7199529 ms away from this server's
//! clock`. Measured through the control plane (a credential deliberately signed
//! 7 199 500 ms fast, so the gate let it through):
//!
//! ```text
//! gunnar control mirror add alice/clockprobe https://github.com/octocat/Hello-World.git
//! server said: github.com could not be resolved: failed to lookup address
//! information: Try again
//! ```
//!
//! **Name resolution on the appliance fails.** And from the front, same estate,
//! same firewall shape:
//!
//! ```text
//! dig +notcp @94.237.40.9 github.com ;; communications error: timed out
//! dig +tcp @94.237.40.9 github.com status: NOERROR, 140.82.121.3
//! dig +notcp @1.1.1.1 github.com ;; communications error: timed out
//! ```
//!
//! UDP replies do not come back while TCP does: the UpCloud firewall's catch-all
//! inbound `drop` (terraform `upcloud_firewall_rules`) drops the *replies* to
//! outbound UDP. That is the same wall the SNTP probes hit ("UDP/123 is
//! blocked"). The front survives it because systemd-resolved retries over TCP;
//! the appliance's musl `getaddrinfo` asks over UDP only, gets `EAI_AGAIN`, so
//! all three HTTPS sources were `Unreachable`, the verdict was `NoQuorum { had: 0 }`,
//! and the clock was — correctly, by the rules — left alone. The log line said
//! so, on a VGA console nobody could read.
//!
//! Three consequences, each a part of this crate:
//!
//! 1. **The appliance does not need the internet, or DNS, for time.** Its source
//! is the front, dialled by IP literal over the utility network, answering
//! with a *signed* timestamp ([`peer`]). Internet sources are an explicit,
//! off-by-default fallback; in an airgap they are simply not configured.
//! 2. **The outcome is data, not a log line** ([`report`]). The last sync — and
//! the boot one — are served on the control plane, including on the one call
//! that needs no credential, because a clock readable only with a credential
//! that the wrong clock refuses is not observable at all.
//! 3. **It keeps running.** A boot-time step fixes the clock the box boots with;
//! nothing fixes drift over months between re-images unless something
//! re-measures. See [`adjust`] for the step-vs-slew rule on a live server.
//!
//! # The modules
//!
//! * [`date`] — RFC 7231 IMF-fixdate to an epoch second. Pure.
//! * [`config`] — the source list a site configures, and what is refused.
//! * [`decide`] — the samples, the sanity window, the quorum and the median. Pure.
//! * [`peer`] — the signed-time protocol (nonce in, `(nonce, unix_ms)` signed out).
//! * [`sntp`] — an SNTPv4 packet, for a site whose root of time is an NTP server.
//! * [`adjust`] — the step-or-slew policy (pure) and the two syscalls (`adjust`).
//! * [`report`] — what happened, kept for the control plane.
//! * `poll` — asking peer and SNTP sources in parallel, no HTTP client (`peer`).
//! * `fetch` — `poll` with the HTTPS sampler added (`fetch`).
//! * `front` — the front's loop over `fetch`: sync this host, serve signed time (`fetch`).
//! * [`offer`] — the front's offer, a box's reading of it, the first fed sync.
pub mod adjust;
pub mod config;
pub mod date;
pub mod decide;
#[cfg(feature = "fetch")]
pub mod fetch;
/// The front's clock loop (keep this host's clock, serve signed time) —
/// shared by `estate-clock run` and the front medium's `gunnar-front`.
#[cfg(feature = "fetch")]
pub mod front;
/// The clock across an installer-built estate: the front's offer, a box's
/// reading of it beside its medium, the first fed sync.
pub mod offer;
#[cfg(feature = "peer")]
pub mod peer;
/// Asking peer and SNTP sources with no HTTP client linked — the poll a
/// zero-egress box runs; `fetch::poll` is it with the HTTPS sampler added.
#[cfg(feature = "peer")]
pub mod poll;
pub mod report;
pub mod sntp;
pub mod trigger;
pub use config::{Config, Role, Source};
pub use decide::{decide, Decision, Sample, Verdict};
pub use report::{Outcome, Report, SourceReport, Trigger};
/// The epoch millisecond this process reads right now. A clock before 1970 is a
/// negative number rather than zero: this crate exists because clocks are wrong,
/// so the one place that reads it must not round the wrongness away.
pub fn now_unix_ms() -> i64 {
unix_ms(std::time::SystemTime::now())
}
/// [`now_unix_ms`] for a given instant.
pub fn unix_ms(at: std::time::SystemTime) -> i64 {
match at.duration_since(std::time::UNIX_EPOCH) {
Ok(since) => since.as_millis() as i64,
Err(before) => -(before.duration().as_millis() as i64),
}
}