crates/estate-clock/src/front.rs
//! **The front's clock, as a loop a process runs** — keep this host's
//! `CLOCK_REALTIME` from the site's sources, and serve signed time to the
//! appliance once this host's own sync has vouched.
//!
//! One implementation, two callers: the `estate-clock run` binary (the old
//! front OS, a systemd unit) and `gunnar-front` (the FRONT medium's PID 1,
//! owner 2026-09-25: vm1 boots its own medium, which carries the clock). The
//! loop is what `estate-clock run` did, moved here unchanged so the two can
//! never disagree about when a front signs.
//!
//! **A front signs only what it can stand behind.** Until its own sync has
//! vouched — and again if the last vouching sync is older than two intervals —
//! it answers `REFUSED`, and the appliance reports that sentence instead of
//! stepping onto a clock nobody checked. `trust_system` is the airgap spelling
//! for "chrony keeps this clock"; it is the only way to sign without a sync.
use std::net::{SocketAddr, TcpListener};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::config::Config;
use crate::report::{Report, Trigger};
/// How soon to retry while this host's clock is not vouched for. A front whose
/// boot sync ran before its network was up must not wait an hour to be right.
pub const RETRY_WHILE_UNVOUCHED: Duration = Duration::from_secs(60);
/// What the loop is told.
pub struct FrontClock {
/// Seconds between re-syncs once vouched (`ESTATE_CLOCK_EVERY`).
pub every: Duration,
/// Sign without syncing (`ESTATE_CLOCK_TRUST_SYSTEM=1`).
pub trust_system: bool,
/// Where to serve signed time, and the key to sign it with. `None`: this
/// host keeps its own clock and serves nobody.
pub serve: Option<(SocketAddr, ssh_key::PrivateKey)>,
}
/// A report as the lines `estate-clock` has always printed.
pub fn report_lines(report: &Report) -> Vec<String> {
let mut lines = vec![format!("clock {}", report.summary())];
for row in &report.sources {
lines.push(match (&row.offset_ms, &row.error) {
(_, Some(why)) => format!(" source {} FAILED: {why}", row.source),
(Some(offset), None) => format!(" source {} {offset:+} ms", row.source),
(None, None) => format!(" source {}", row.source),
});
}
lines
}
/// **Whether a front may sign now** — pure over when its last vouching sync
/// was. `Ok` to sign; `Err` is the sentence the appliance is answered with.
pub fn standing(trust_system: bool, last_good: Option<Instant>, stale_after: Duration) -> Result<(), String> {
if trust_system {
return Ok(());
}
match last_good {
Some(at) if at.elapsed() <= stale_after => Ok(()),
Some(at) => Err(format!("this front's last good clock sync was {} s ago", at.elapsed().as_secs())),
None => Err("this front's own clock has not synchronised yet".into()),
}
}
/// **Run the front's clock, forever.** Starts the signed-time listener (if
/// asked) on its own thread — retrying the bind, because the address may
/// arrive after this starts — then syncs this host's clock, again every
/// `every` once vouched and every [`RETRY_WHILE_UNVOUCHED`] until then. Every
/// line it has to say goes through `say`.
pub fn run(config: &Result<Config, String>, clock: FrontClock, say: Arc<dyn Fn(&str) + Send + Sync>) -> ! {
let last_good: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
if let Some((addr, signer)) = clock.serve {
say(&format!(
"serving signed time on {addr} as {} (namespace {})",
signer.public_key().fingerprint(ssh_key::HashAlg::Sha256),
crate::peer::NAMESPACE
));
let last_good = Arc::clone(&last_good);
let stale_after = clock.every * 2 + Duration::from_secs(60);
let trust_system = clock.trust_system;
let say = Arc::clone(&say);
// A32-14, stays a thread: the signed-time accept loop, for the life of the front's clock.
std::thread::spawn(move || loop {
match TcpListener::bind(addr) {
Ok(listener) => {
let vouched = || standing(trust_system, *last_good.lock().unwrap_or_else(|p| p.into_inner()), stale_after);
crate::peer::serve(listener, &signer, &vouched, &|peer, standing| match standing {
Ok(()) => say(&format!("signed time for {peer}")),
Err(why) => say(&format!("refused to sign time for {peer}: {why}")),
});
}
Err(err) => {
// The utility address may arrive after this starts.
say(&format!("cannot bind {addr} yet ({err}); retrying in 5 s"));
std::thread::sleep(Duration::from_secs(5));
}
}
});
}
let mut phase = Trigger::Boot;
loop {
let report = crate::fetch::sync(config, phase);
for line in report_lines(&report) {
say(&line);
}
let vouches = report.outcome.vouches();
if vouches {
*last_good.lock().unwrap_or_else(|p| p.into_inner()) = Some(Instant::now());
}
phase = Trigger::Timer;
std::thread::sleep(if vouches { clock.every } else { RETRY_WHILE_UNVOUCHED.min(clock.every) });
}
}
/// **The front's time key, minted (0600) if absent**, and its public half as
/// the whole OpenSSH line — what the appliance pins as `ESTATE_CLOCK_KEY`
/// (the line or its blob; [`Config::parse`] takes either). An existing key is
/// somebody's trust anchor and is never overwritten.
pub fn ensure_key(path: &std::path::Path) -> Result<(ssh_key::PrivateKey, String), String> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let key = match ssh_key::PrivateKey::read_openssh_file(path) {
Ok(k) => k,
Err(_) if !path.exists() => {
let key = crate::peer::generate_key()?;
let pem = key.to_openssh(ssh_key::LineEnding::LF).map_err(|e| e.to_string())?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
}
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.map_err(|err| format!("creating {}: {err} (an existing key is never overwritten)", path.display()))?;
file.write_all(pem.as_bytes()).map_err(|e| e.to_string())?;
key
}
Err(e) => return Err(format!("reading the signing key {}: {e}", path.display())),
};
let line = key.public_key().to_openssh().map_err(|e| e.to_string())?;
Ok((key, line))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_front_signs_only_after_a_vouching_sync_that_is_not_stale() {
let stale_after = Duration::from_secs(3600);
assert!(standing(false, None, stale_after).unwrap_err().contains("has not synchronised yet"));
assert!(standing(false, Some(Instant::now()), stale_after).is_ok());
assert!(standing(true, None, stale_after).is_ok(), "the airgap spelling signs without a sync");
let long_ago = Instant::now().checked_sub(Duration::from_secs(7200));
if let Some(at) = long_ago {
assert!(standing(false, Some(at), stale_after).unwrap_err().contains("last good clock sync was"));
}
}
#[test]
fn the_time_key_is_minted_once_0600_and_its_line_is_what_the_appliance_pins() {
use std::os::unix::fs::PermissionsExt as _;
let dir = std::env::temp_dir().join(format!("estate-clock-front-{}-{:?}", std::process::id(), std::thread::current().id()));
let path = dir.join("clock/time_ed25519");
let _ = std::fs::remove_dir_all(&dir);
let (_, line) = ensure_key(&path).unwrap();
assert!(line.starts_with("ssh-ed25519 "), "{line}");
assert_eq!(ensure_key(&path).unwrap().1, line, "minted once");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
let parsed = Config::parse(Some("peer://10.13.8.1:50123"), Some(&line), crate::config::Role::Appliance).unwrap();
assert!(matches!(parsed, Config::Sources { peer_key: Some(_), .. }), "the appliance takes the whole line");
let _ = std::fs::remove_dir_all(&dir);
}
}