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