crates/estate-clock/src/config.rs
//! **Where "now" comes from is configuration, never code.**
//!
//! One spelling for both machines:
//!
//! ```text
//! ESTATE_CLOCK=peer://10.13.8.99:50123 the appliance on gunnar.rs
//! ESTATE_CLOCK_KEY=AAAAC3NzaC1lZDI1NTE5AAAAI… the front's time key (public)
//! ESTATE_CLOCK=https://api.stripe.com/,https://www.cloudflare.com/,https://www.google.com/
//! the front on gunnar.rs (UDP is dropped)
//! ESTATE_CLOCK=ntp://10.0.0.1 a front in an airgap, with a site NTP server
//! ESTATE_CLOCK=off declined, by name
//! ```
//!
//! On the appliance it rides the kernel command line (`korp.env.ESTATE_CLOCK=`,
//! `korp.env.ESTATE_CLOCK_KEY=`), the only configuration channel a box with no
//! `/etc` and no shell has; on the front it is the unit's environment file.
//!
//! **Unset is "not configured", and not configured does nothing.** The first cut
//! of this code defaulted to three internet hosts. An appliance sold into an
//! airgap would then spend every boot dialling Stripe and report a failure that
//! is not one. A default source is a decision about somebody else's network made
//! in this repository, so there is none: the report says `not configured`, and
//! that sentence is what an operator reads instead of a silence.
use std::net::SocketAddr;
/// The sources, on the kernel command line or in a unit's environment.
pub const CLOCK_ENV: &str = "ESTATE_CLOCK";
/// The peer's ed25519 public key: the base64 blob of an OpenSSH public key line
/// (the middle field — a kernel command line splits on spaces, so the
/// `ssh-ed25519 ` prefix cannot ride it), or the whole line where spaces can.
pub const CLOCK_KEY_ENV: &str = "ESTATE_CLOCK_KEY";
/// The prefix of every variable this crate reads.
pub const ENV_PREFIX: &str = "ESTATE_CLOCK";
/// **Gunnar's spelling of the same variables** (gunnar-clock's `GUNNAR_CLOCK*`):
/// read as an alias of each `ESTATE_CLOCK*` while Gunnar moves onto this crate,
/// so a medium baked with `korp.env.GUNNAR_CLOCK=` keeps its clock across the
/// two releases (lane G3b, 2026-10-21). Dropped after the move.
pub const GUNNAR_ENV_PREFIX: &str = "GUNNAR_CLOCK";
/// The Gunnar alias of an `ESTATE_CLOCK*` name (`ESTATE_CLOCK_KEY` →
/// `GUNNAR_CLOCK_KEY`); `None` for any other name.
pub fn gunnar_alias(name: &str) -> Option<String> {
let rest = name.strip_prefix(ENV_PREFIX)?;
(rest.is_empty() || rest.starts_with('_')).then(|| format!("{GUNNAR_ENV_PREFIX}{rest}"))
}
/// A variable through `lookup`: the `ESTATE_CLOCK*` name, else its Gunnar
/// alias ([`gunnar_alias`]). A blank value is no value, so a blank estate name
/// does not hide a set Gunnar one; both set, the estate name wins.
pub fn var_with(name: &str, lookup: &dyn Fn(&str) -> Option<String>) -> Option<String> {
let set = |n: &str| lookup(n).filter(|v| !v.trim().is_empty());
set(name).or_else(|| gunnar_alias(name).and_then(|alias| set(&alias)))
}
/// [`var_with`] over the process environment. Every variable this crate reads
/// is read through here.
pub fn var(name: &str) -> Option<String> {
var_with(name, &|n| std::env::var(n).ok())
}
/// The port `peer://` uses when the spelling names none. `50123`: beside the
/// control plane's `50051`/`50052`, and it ends in NTP's 123 so nobody has to
/// look it up.
pub const DEFAULT_PEER_PORT: u16 = 50123;
/// Which machine is reading the configuration. The rules differ in exactly one
/// place — see [`Config::parse`] on `ntp://`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// The appliance: its time decides whether a credential or a payment
/// signature verifies, and it has an authenticated source to use.
Appliance,
/// The front: the estate's root of time. It takes whatever the site has.
Front,
}
/// One configured source.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
/// `https://…` — the `Date` header of a `HEAD`. Authenticated by the TLS
/// certificate; second-resolution; needs DNS and TCP/443.
Https(String),
/// `ntp://host[:port]` — SNTPv4. Millisecond-resolution and **plaintext**:
/// anybody on the path can rewrite it. Front only (see [`Config::parse`]).
Ntp { host: String, port: u16 },
/// `peer://ip:port` — a signed `(nonce, unix_ms)` from a key named in
/// [`CLOCK_KEY_ENV`]. An IP literal on purpose: the point of this source is
/// that it needs no name service.
Peer(SocketAddr),
}
impl Source {
/// The spelling an operator wrote, for reports and log lines.
pub fn label(&self) -> String {
match self {
Source::Https(url) => url.clone(),
Source::Ntp { host, port } => format!("ntp://{host}:{port}"),
Source::Peer(addr) => format!("peer://{addr}"),
}
}
}
/// What the configuration said.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Config {
/// Nothing set. Not an error and not a default: nothing is asked.
NotConfigured,
/// `off`, spelled. Declined by name.
Off,
/// The sources to ask, in order, and the peer key when a `peer://` is among
/// them (normalised to an OpenSSH public key line).
Sources {
sources: Vec<Source>,
peer_key: Option<String>,
},
}
impl Config {
/// Read the process environment.
pub fn from_env(role: Role) -> Result<Config, String> {
Config::parse(
var(CLOCK_ENV).as_deref(),
var(CLOCK_KEY_ENV).as_deref(),
role,
)
}
/// Parse the two values. Absent is a default, a recognised word is a
/// decision, anything else is a **refusal by name** — never a silent
/// fallback, and never a list quietly shorter than it reads.
///
/// * `http://` is refused everywhere: a `Date` anybody on the path can
/// rewrite, from a host that could have spoken TLS.
/// * `ntp://` is refused on the **appliance**. It is plaintext UDP, and the
/// appliance has an authenticated source ([`Source::Peer`]); taking the
/// unauthenticated one there would hand "now" — and with it the replay
/// window of every credential — to whoever sits on the path. The **front**
/// may name one, because in an airgap a site NTP server is the only root of
/// time there is, and naming it is the site's decision to make.
/// * `peer://` without a key is refused: an unsigned peer is the plaintext
/// source again, wearing a better name.
pub fn parse(raw: Option<&str>, raw_key: Option<&str>, role: Role) -> Result<Config, String> {
let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(Config::NotConfigured);
};
if raw.eq_ignore_ascii_case("off") {
return Ok(Config::Off);
}
let mut sources = Vec::new();
for spelling in raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
sources.push(parse_source(spelling, role)?);
}
if sources.is_empty() {
return Err(format!(
"{CLOCK_ENV} names no source. Spell `off` to decline; an empty list reads as a \
mistake, not as a decision."
));
}
let wants_key = sources.iter().any(|s| matches!(s, Source::Peer(_)));
let peer_key = match raw_key.map(str::trim).filter(|k| !k.is_empty()) {
Some(key) => Some(normalise_key(key)?),
None if wants_key => {
return Err(format!(
"{CLOCK_ENV} names a peer:// source and {CLOCK_KEY_ENV} is not set. A peer \
whose signature nobody checks is a plaintext source; it is refused rather \
than trusted."
))
}
None => None,
};
Ok(Config::Sources { sources, peer_key })
}
}
fn parse_source(spelling: &str, role: Role) -> Result<Source, String> {
if spelling.starts_with("https://") {
return Ok(Source::Https(spelling.to_owned()));
}
if let Some(rest) = spelling.strip_prefix("peer://") {
let rest = rest.trim_end_matches('/');
let addr = rest
.parse::<SocketAddr>()
.or_else(|_| {
rest.parse::<std::net::IpAddr>()
.map(|ip| SocketAddr::new(ip, DEFAULT_PEER_PORT))
})
.map_err(|_| {
format!(
"{CLOCK_ENV} names {spelling}; a peer is an IP literal (`peer://10.13.8.99:50123`), \
never a hostname — this source exists so time does not depend on DNS."
)
})?;
return Ok(Source::Peer(addr));
}
if let Some(rest) = spelling.strip_prefix("ntp://") {
if role == Role::Appliance {
return Err(format!(
"{CLOCK_ENV} names {spelling} on the appliance. SNTP is unauthenticated UDP and \
this clock decides whether a credential or a signed fact verifies; use \
peer:// (signed) for the appliance, and give the FRONT the NTP server."
));
}
let rest = rest.trim_end_matches('/');
let (host, port) = match rest.rsplit_once(':') {
Some((host, port)) if !host.contains(':') || host.starts_with('[') => (
host.trim_start_matches('[')
.trim_end_matches(']')
.to_owned(),
port.parse::<u16>().map_err(|_| {
format!("{CLOCK_ENV} names {spelling}, whose port is not a port")
})?,
),
_ => (rest.to_owned(), 123),
};
if host.is_empty() {
return Err(format!("{CLOCK_ENV} names {spelling}, which has no host"));
}
return Ok(Source::Ntp { host, port });
}
Err(format!(
"{CLOCK_ENV} names {spelling}, which is none of https://, peer:// or ntp://. A plaintext \
http:// Date is refused: anybody on the path could rewrite it, and this clock gates \
credentials and signed facts."
))
}
/// `ssh-ed25519 AAAA… [comment]` or the bare `AAAA…` blob → `ssh-ed25519 AAAA…`.
///
/// Only the SHAPE is checked here (the crate's default build has no key parser);
/// [`crate::peer::PeerKey::parse`] is where the bytes are proven to be a key.
fn normalise_key(raw: &str) -> Result<String, String> {
let mut fields = raw.split_whitespace();
let (algorithm, blob) = match (fields.next(), fields.next()) {
(Some(alg), Some(blob)) => (alg, blob),
(Some(blob), None) => ("ssh-ed25519", blob),
_ => return Err(format!("{CLOCK_KEY_ENV} is empty")),
};
if algorithm != "ssh-ed25519" {
return Err(format!(
"{CLOCK_KEY_ENV} is a {algorithm} key; the time signature is ed25519 only"
));
}
let base64 = |b: u8| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=';
if blob.is_empty() || !blob.bytes().all(base64) {
return Err(format!(
"{CLOCK_KEY_ENV} is not the base64 blob of an OpenSSH public key"
));
}
Ok(format!("ssh-ed25519 {blob}"))
}
/// **The front's own time sources on a cloud account** (Gunnar's kit
/// `FRONT_CLOCK_SOURCES`, private-gunnar-ops `install.rs`, lane D 2026-09-25;
/// Holger's xtask R10): the estate's root of time reads them and vouches for
/// its signed time on `<utility>:50123`; without them it never vouches and
/// every box's clock refuses the front. Both kits compose a front medium with
/// `clock=` [`front_sources`].
pub const CLOUD_SOURCES: &str = "https://api.stripe.com/,https://www.cloudflare.com/";
/// Where an OFFLINE mock guest reaches the bench machine: mock-upcloud-kvm
/// `--offline` relays `10.0.2.100:<port>` to the bench's `127.0.0.1:<port>`.
pub const BENCH_HOST_ALIAS: std::net::Ipv4Addr = std::net::Ipv4Addr::new(10, 0, 2, 100);
/// The bench clock's default port (Gunnar's kit `bench_clock::PORT`).
pub const BENCH_CLOCK_PORT: u16 = 18453;
/// Where a front medium is laid, for its time sources.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrontPlace {
/// A real account (UpCloud, on-premises): [`CLOUD_SOURCES`].
Cloud,
/// The KVM mock: ONE local source, the bench clock (owner 2026-09-26,
/// "LOCAL clock": a mock front never asks the internet for the time).
Bench,
}
/// The bench clock as a source: `https://10.0.2.100:<port>/` (its certificate
/// is the medium's installer CA's; the front adds that CA,
/// `offer::start`'s `installer_ca_pem`).
pub fn bench_source(port: u16) -> String {
format!("https://{BENCH_HOST_ALIAS}:{port}/")
}
/// **The front's time sources for `place`** — what a kit composes the front
/// medium with as `clock=`. `bench_port` is read only for
/// [`FrontPlace::Bench`].
pub fn front_sources(place: FrontPlace, bench_port: u16) -> String {
match place {
FrontPlace::Cloud => CLOUD_SOURCES.to_string(),
FrontPlace::Bench => bench_source(bench_port),
}
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "AAAAC3NzaC1lZDI1NTE5AAAAIG6bqWQf0Qb2cKq5qfV2F2mA0w9o5nXx8m1kq3m0Zb3F";
#[test]
fn unset_is_not_configured_and_asks_nobody() {
// The airgap default: no internet host is dialled because a repository
// said so.
assert_eq!(
Config::parse(None, None, Role::Appliance),
Ok(Config::NotConfigured)
);
assert_eq!(
Config::parse(Some(" "), None, Role::Front),
Ok(Config::NotConfigured)
);
}
#[test]
fn off_declines_by_name() {
assert_eq!(
Config::parse(Some("off"), None, Role::Appliance),
Ok(Config::Off)
);
assert_eq!(
Config::parse(Some("OFF"), None, Role::Front),
Ok(Config::Off)
);
}
#[test]
fn a_plaintext_http_source_is_refused_by_name_for_both_roles() {
for role in [Role::Appliance, Role::Front] {
let refusal = Config::parse(Some("http://time.example/"), None, role)
.expect_err("http:// is refused");
assert!(refusal.contains("http://time.example/"));
}
// One bad entry refuses the whole list rather than quietly dropping it.
assert!(Config::parse(
Some("https://a.example/,http://b.example/"),
None,
Role::Front
)
.is_err());
}
#[test]
fn ntp_is_refused_on_the_appliance_and_accepted_on_the_front() {
let refusal = Config::parse(Some("ntp://10.0.0.1"), None, Role::Appliance)
.expect_err("the appliance has an authenticated source to use");
assert!(refusal.contains("peer://"));
assert_eq!(
Config::parse(
Some("ntp://10.0.0.1, ntp://ntp.site:4123"),
None,
Role::Front
),
Ok(Config::Sources {
sources: vec![
Source::Ntp {
host: "10.0.0.1".into(),
port: 123
},
Source::Ntp {
host: "ntp.site".into(),
port: 4123
},
],
peer_key: None,
})
);
}
#[test]
fn a_peer_is_an_ip_literal_and_takes_the_default_port() {
let Ok(Config::Sources { sources, peer_key }) =
Config::parse(Some("peer://10.13.8.99"), Some(KEY), Role::Appliance)
else {
panic!("a peer with a key parses");
};
assert_eq!(
sources,
vec![Source::Peer("10.13.8.99:50123".parse().unwrap())]
);
assert_eq!(
peer_key.as_deref(),
Some(format!("ssh-ed25519 {KEY}").as_str())
);
// A hostname would need DNS, which is the thing this source avoids.
assert!(Config::parse(
Some("peer://front.estate.example:50123"),
Some(KEY),
Role::Appliance
)
.is_err());
}
#[test]
fn a_peer_without_a_key_is_refused_rather_than_trusted() {
let refusal = Config::parse(Some("peer://10.13.8.99:50123"), None, Role::Appliance)
.expect_err("unsigned peer");
assert!(refusal.contains(CLOCK_KEY_ENV));
}
#[test]
fn the_key_is_accepted_as_a_bare_blob_or_a_whole_line_and_nothing_else() {
let line = format!("ssh-ed25519 {KEY} time@front");
let a = Config::parse(Some("peer://10.0.0.1:1"), Some(KEY), Role::Appliance).unwrap();
let b = Config::parse(Some("peer://10.0.0.1:1"), Some(&line), Role::Appliance).unwrap();
assert_eq!(a, b);
assert!(Config::parse(
Some("peer://10.0.0.1:1"),
Some("ssh-rsa AAAA"),
Role::Appliance
)
.is_err());
assert!(Config::parse(
Some("peer://10.0.0.1:1"),
Some("not base64!"),
Role::Appliance
)
.is_err());
}
#[test]
fn an_empty_list_is_a_mistake_and_says_so() {
assert!(Config::parse(Some(","), None, Role::Front).is_err());
}
/// Gunnar's kit `front_clock_param_at` (private-gunnar-ops install.rs), the same words.
#[test]
fn the_front_sources_are_the_accounts_or_the_bench_clock() {
assert_eq!(front_sources(FrontPlace::Cloud, BENCH_CLOCK_PORT), "https://api.stripe.com/,https://www.cloudflare.com/", "the account keeps its real sources");
assert_eq!(front_sources(FrontPlace::Bench, BENCH_CLOCK_PORT), "https://10.0.2.100:18453/", "the mock: the local bench clock only");
assert_eq!(front_sources(FrontPlace::Bench, 18791), "https://10.0.2.100:18791/");
for place in [FrontPlace::Cloud, FrontPlace::Bench] {
let parsed = Config::parse(Some(&front_sources(place, BENCH_CLOCK_PORT)), None, Role::Front);
assert!(matches!(parsed, Ok(Config::Sources { ref sources, .. }) if !sources.is_empty() && sources.iter().all(|s| matches!(s, Source::Https(_)))), "{place:?}: {parsed:?}");
}
}
/// GUNNAR_CLOCK* read as ESTATE_CLOCK* (lane G3b).
mod gunnar_alias {
use super::super::*;
use super::KEY;
use std::collections::BTreeMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let m: BTreeMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |n| m.get(n).cloned()
}
#[test]
fn every_estate_name_has_its_gunnar_alias() {
for (estate, gunnar) in [
(CLOCK_ENV, "GUNNAR_CLOCK"),
(CLOCK_KEY_ENV, "GUNNAR_CLOCK_KEY"),
(crate::offer::TRUST_SYSTEM_ENV, "GUNNAR_CLOCK_TRUST_SYSTEM"),
("ESTATE_CLOCK_EVERY", "GUNNAR_CLOCK_EVERY"),
("ESTATE_CLOCK_SERVE", "GUNNAR_CLOCK_SERVE"),
("ESTATE_CLOCK_SIGNING_KEY", "GUNNAR_CLOCK_SIGNING_KEY"),
] {
assert_eq!(gunnar_alias(estate).as_deref(), Some(gunnar), "{estate}");
}
assert_eq!(gunnar_alias("PATH"), None);
assert_eq!(gunnar_alias("ESTATE_CLOCKWORK"), None, "only the name or the name plus `_…`");
}
#[test]
fn a_gunnar_name_alone_is_read() {
let e = env(&[("GUNNAR_CLOCK", "peer://10.13.8.99:50123"), ("GUNNAR_CLOCK_KEY", KEY)]);
assert_eq!(var_with(CLOCK_ENV, &e).as_deref(), Some("peer://10.13.8.99:50123"));
assert_eq!(var_with(CLOCK_KEY_ENV, &e).as_deref(), Some(KEY));
}
#[test]
fn the_estate_name_wins_and_a_blank_one_hides_nothing() {
let e = env(&[("ESTATE_CLOCK", "off"), ("GUNNAR_CLOCK", "peer://10.13.8.99:50123")]);
assert_eq!(var_with(CLOCK_ENV, &e).as_deref(), Some("off"));
let e = env(&[("ESTATE_CLOCK", " "), ("GUNNAR_CLOCK", "peer://10.13.8.99:50123")]);
assert_eq!(var_with(CLOCK_ENV, &e).as_deref(), Some("peer://10.13.8.99:50123"));
assert_eq!(var_with(CLOCK_ENV, &env(&[])), None);
}
}
}