Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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);
    }
}