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/bin/estate-clock.rs

//! `estate-clock` — the FRONT's clock, and the signed time the appliance follows.
//!
//! ```text
//!   estate-clock run                  keep this host's clock; serve signed time if configured
//!   estate-clock sync                 one boot-phase sync, print the report, exit 0 iff it vouches
//!   estate-clock keygen <path>        a fresh ed25519 time key (0600); prints ESTATE_CLOCK_KEY=
//!   estate-clock pubkey <path>        print ESTATE_CLOCK_KEY= for an existing key
//!   estate-clock check <ip:port> <ESTATE_CLOCK_KEY value>
//!                                     one signed exchange with a peer; prints its offset
//! ```
//!
//! Configuration is the environment (a systemd `EnvironmentFile=`), so a site
//! changes its source without a rebuild:
//!
//! ```text
//!   ESTATE_CLOCK=https://api.stripe.com/,https://www.cloudflare.com/,https://www.google.com/
//!   ESTATE_CLOCK_SERVE=10.13.8.99:50123       where to serve signed time (unset: don't)
//!   ESTATE_CLOCK_SIGNING_KEY=/etc/estate-clock/time_ed25519
//!   ESTATE_CLOCK_EVERY=3600                   seconds between re-syncs
//!   ESTATE_CLOCK_TRUST_SYSTEM=1               another daemon (chrony) owns this clock:
//!                                             sign it without syncing it
//! ```
//!
//! **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. `ESTATE_CLOCK_TRUST_SYSTEM=1` is the
//! airgap spelling for "chrony keeps this clock"; it is a statement by the site,
//! and it is the only way to sign without a sync.

use std::net::SocketAddr;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;

use estate_clock::config::{Config, Role};
use estate_clock::report::{Report, Trigger};

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let result = match args.first().map(String::as_str) {
        Some("run") => run(),
        Some("sync") => sync_once(),
        Some("keygen") => args.get(1).map_or_else(|| Err(usage()), |p| keygen(p)),
        Some("pubkey") => args.get(1).map_or_else(|| Err(usage()), |p| pubkey(p)),
        Some("check") => match (args.get(1), args.get(2)) {
            (Some(addr), Some(key)) => check(addr, key),
            _ => Err(usage()),
        },
        _ => Err(usage()),
    };
    match result {
        Ok(code) => code,
        Err(why) => {
            eprintln!("estate-clock: {why}");
            ExitCode::from(2)
        }
    }
}

fn usage() -> String {
    "usage: estate-clock run | sync | keygen <path> | pubkey <path> | check <ip:port> <key>".into()
}

fn print(report: &Report) {
    for line in estate_clock::front::report_lines(report) {
        println!("{line}");
    }
}

fn sync_once() -> Result<ExitCode, String> {
    let report = estate_clock::fetch::sync(&Config::from_env(Role::Front), Trigger::Boot);
    print(&report);
    Ok(if report.outcome.vouches() {
        ExitCode::SUCCESS
    } else {
        ExitCode::FAILURE
    })
}

/// `name`, or its Gunnar alias (`GUNNAR_CLOCK*`, [`estate_clock::config::var`]).
fn env(name: &str) -> Option<String> {
    estate_clock::config::var(name)
        .map(|v| v.trim().to_owned())
        .filter(|v| !v.is_empty())
}

fn run() -> Result<ExitCode, String> {
    let config = Config::from_env(Role::Front);
    let every =
        match env("ESTATE_CLOCK_EVERY") {
            Some(raw) => {
                Duration::from_secs(raw.parse::<u64>().ok().filter(|s| *s >= 60).ok_or_else(
                    || format!("ESTATE_CLOCK_EVERY={raw} is not a number of seconds >= 60"),
                )?)
            }
            None => estate_clock::adjust::PERIODIC_INTERVAL,
        };
    let trust_system = env("ESTATE_CLOCK_TRUST_SYSTEM").as_deref() == Some("1");
    let serve = match env("ESTATE_CLOCK_SERVE") {
        Some(serve_at) => {
            let addr: SocketAddr = serve_at.parse().map_err(|_| format!("ESTATE_CLOCK_SERVE={serve_at} is not an ip:port"))?;
            let key_path = env("ESTATE_CLOCK_SIGNING_KEY").ok_or_else(|| "ESTATE_CLOCK_SERVE is set and ESTATE_CLOCK_SIGNING_KEY is not".to_owned())?;
            Some((addr, ssh_key_read(&key_path)?))
        }
        None => None,
    };
    // The loop is the library's (`estate_clock::front`), shared with the front
    // medium's `gunnar-front`: one rule for when a front signs.
    estate_clock::front::run(&config, estate_clock::front::FrontClock { every, trust_system, serve }, Arc::new(|line: &str| println!("{line}")))
}

fn ssh_key_read(path: &str) -> Result<ssh_key::PrivateKey, String> {
    ssh_key::PrivateKey::read_openssh_file(std::path::Path::new(path))
        .map_err(|err| format!("reading the signing key {path}: {err}"))
}

fn blob_of(public: &ssh_key::PublicKey) -> Result<String, String> {
    let line = public.to_openssh().map_err(|e| e.to_string())?;
    line.split_whitespace()
        .nth(1)
        .map(str::to_owned)
        .ok_or_else(|| "the public key line has no blob".to_owned())
}

fn keygen(path: &str) -> Result<ExitCode, String> {
    use std::io::Write as _;
    use std::os::unix::fs::OpenOptionsExt as _;
    let key = estate_clock::peer::generate_key()?;
    let pem = key
        .to_openssh(ssh_key::LineEnding::LF)
        .map_err(|e| e.to_string())?;
    // create_new: an existing key is somebody's trust anchor, never overwritten.
    let mut file = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(path)
        .map_err(|err| format!("creating {path}: {err} (an existing key is never overwritten)"))?;
    file.write_all(pem.as_bytes()).map_err(|e| e.to_string())?;
    println!("wrote {path}");
    println!("ESTATE_CLOCK_KEY={}", blob_of(key.public_key())?);
    Ok(ExitCode::SUCCESS)
}

fn pubkey(path: &str) -> Result<ExitCode, String> {
    let key = ssh_key_read(path)?;
    println!("ESTATE_CLOCK_KEY={}", blob_of(key.public_key())?);
    Ok(ExitCode::SUCCESS)
}

fn check(addr: &str, key: &str) -> Result<ExitCode, String> {
    let addr: SocketAddr = addr
        .parse()
        .map_err(|_| format!("{addr} is not an ip:port"))?;
    let config = Config::parse(Some(&format!("peer://{addr}")), Some(key), Role::Appliance)?;
    let Config::Sources {
        peer_key: Some(line),
        ..
    } = config
    else {
        return Err("no peer key".into());
    };
    let key = estate_clock::peer::PeerKey::parse(&line)?;
    let sample = estate_clock::peer::query(addr, &key)?;
    println!(
        "peer {} verified ({}): peer - local = {:+} ms",
        sample.source,
        key.fingerprint(),
        sample.offset_ms
    );
    Ok(ExitCode::SUCCESS)
}