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