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

nordisk/holger

holger-estate-front/src/front.rs

//! **holger's half of vm1's `Vm`** — what only holger's front knows, under
//! Skidbladnir `nordisk-pairing::frontside::Front` (kind `front`, only role
//! `front`, PAIRED once the relay is up and every box is listed).
//!
//! | offer | value | who takes it |
//! |---|---|---|
//! | `version` | this build (`HOLGER_FRONT_GIT_HASH`) | the operator's install check |
//! | `fleet`, `fleet_instance`, `fleet_at`, `fleet_ca`, `monetize_pubkey` | the ONE monetize's door, this estate's name there, the key its orders verify under — relayed from the shop's feed (paid front) | every metered holger box's pull channel (holger-fleet) |
//! | `clock_key` | the front's time key, the whole OpenSSH line (edda `estate-clock`) | every holger box's clock |
//! | `clock_peer` | `peer://<utility>:50123`, where signed time is served | every holger box's clock |
//!
//! Its SECRET offer (asked once, before the installer closes): every valid web
//! certificate kept on the data disk with its key, `tls.<host>.cert|key` — the
//! successor front is fed exactly that and asks Let's Encrypt nothing (edda
//! `estate-front::web::certs::handover`).

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use nordisk_pairing::bootstrap::SecretOffer;
use nordisk_pairing::frontside::FrontProduct;
use nordisk_pairing::join::JoinReply;
use nordisk_pairing::seed::TunnelKind;

/// The offer key of the build (the boxes' word).
pub const OFFER_VERSION: &str = "version";
/// **The ONE monetize's words, relayed to every box** (paid front): what the shop's
/// pairing fed this front, offered as `offer.<front>.<key>` (holger-fleet `config`).
pub const RELAYED: [&str; 5] = [
    "fleet",
    "fleet_instance",
    "fleet_at",
    "fleet_ca",
    "monetize_pubkey",
];
/// Where the front keeps its tunnel key (the relay's), under the data root —
/// the same place a holger box keeps its own.
pub const WG_REL: &str = "wg";

/// **holger's front.**
pub struct HolgerFront {
    root: PathBuf,
    tunnel: TunnelKind,
    clock: Mutex<Option<estate_clock::offer::ClockOffer>>,
    /// **The front's own live slot** (L20, holger-live): `None` off a korp appliance — the live
    /// door then refuses by name and the shop keep-boots the front.
    live: Option<std::sync::Arc<holger_live::LiveSlot>>,
}

impl HolgerFront {
    pub fn new(root: &Path, tunnel: TunnelKind) -> HolgerFront {
        HolgerFront {
            root: root.to_path_buf(),
            tunnel,
            clock: Mutex::new(None),
            live: None,
        }
    }

    /// The front with its own live slot.
    pub fn with_live(mut self, slot: std::sync::Arc<holger_live::LiveSlot>) -> HolgerFront {
        self.live = Some(slot);
        self
    }

    /// The front's own live slot, if it has one.
    pub fn live_slot(&self) -> Option<&std::sync::Arc<holger_live::LiveSlot>> {
        self.live.as_ref()
    }

    /// The clock started (edda `estate-clock`): its key and peer are offered
    /// from now on, so every box follows this front's signed time.
    pub fn clock_started(&self, offer: estate_clock::offer::ClockOffer) {
        if let Ok(mut c) = self.clock.lock() {
            *c = Some(offer);
        }
    }

    /// The relay's private key — the key the presentation carried.
    pub fn relay_key(&self) -> Result<[u8; 32], String> {
        wg_appliance_core::ceremony::load_or_mint_identity(&self.root.join(WG_REL))
            .map(|id| id.private)
            .map_err(|e| format!("the front's tunnel key: {e}"))
    }
}

impl FrontProduct for HolgerFront {
    fn tunnel_pubkey(&self) -> String {
        if self.tunnel != TunnelKind::Wireguard {
            return String::new();
        }
        match wg_appliance_core::ceremony::load_or_mint_identity(&self.root.join(WG_REL)) {
            Ok(id) => id.public_b64,
            Err(e) => {
                tracing::error!(error = %e, "front: its tunnel key cannot be minted — the join carries none, the relay cannot start");
                String::new()
            }
        }
    }

    fn offer(&self, fed: Option<&JoinReply>) -> BTreeMap<String, String> {
        let mut o = BTreeMap::from([(OFFER_VERSION.to_string(), crate::GIT_HASH.to_string())]);
        // The console key's public half (A7 row 14): every box derives its console login from it.
        match crate::console_key::adopt(&self.root) {
            Ok(public) => {
                o.insert(crate::console_key::OFFER_CONSOLE_PUBKEY.to_string(), public);
            }
            Err(e) => {
                tracing::error!(error = %e, "front: the console's key cannot be minted — no box can derive the console's login")
            }
        }
        if let Some(c) = self.clock.lock().ok().and_then(|c| c.clone()) {
            o.extend(c.rows());
        }
        // Lane HOL-WGDS: the downstreams' interface (its key and public endpoint), on an
        // estate: main keeps it for the invites of its WireGuard downstreams.
        if self.tunnel == TunnelKind::Wireguard {
            match crate::downstreams::identity(&self.root) {
                Ok(id) => o.extend(crate::downstreams::offer(
                    &id.public_b64,
                    appliance_pid1::net::interface_ipv4(crate::PUBLIC_INTERFACE)
                        .ok()
                        .flatten(),
                )),
                Err(e) => {
                    tracing::error!(error = %e, "front: the downstreams' key cannot be minted — no WireGuard downstream can be invited")
                }
            }
        }
        // The ONE monetize's door and key, as the shop's pairing fed them: every box
        // reports there and verifies its orders under the key (no monetize runs here).
        #[cfg(feature = "metered")]
        if let Some(r) = fed {
            o.extend(relayed(&r.feed));
        }
        #[cfg(not(feature = "metered"))]
        let _ = fed;
        o
    }

    fn live_running(&self) -> Result<nordisk_pairing::live::Running, String> {
        self.live
            .as_ref()
            .ok_or_else(|| {
                "REFUSED [live-unsupported] this front runs off a korp appliance: no live slot"
                    .to_string()
            })?
            .running()
    }

    fn live_stage(&self, stage: &nordisk_pairing::live::Stage) -> Result<String, String> {
        self.live
            .as_ref()
            .ok_or_else(|| {
                "REFUSED [live-unsupported] this front runs off a korp appliance: no live slot"
                    .to_string()
            })?
            .stage(stage)
    }

    /// Lane B5: with what the release declares (korp `stage_declared` on the front's own slot).
    fn live_stage_declared(
        &self,
        stage: &nordisk_pairing::live::Stage,
        declared: &nordisk_pairing::live::Declared,
    ) -> Result<String, String> {
        self.live
            .as_ref()
            .ok_or_else(|| {
                "REFUSED [live-unsupported] this front runs off a korp appliance: no live slot"
                    .to_string()
            })?
            .stage_declared(stage, declared)
    }

    fn secret_offer(&self) -> SecretOffer {
        let (values, hosts) =
            estate_front::web::certs::handover(&self.root, std::time::SystemTime::now());
        if hosts.is_empty() {
            tracing::info!("installer: no valid web certificate kept on the data disk — nothing to hand over to the next front");
        } else {
            tracing::info!(hosts = ?hosts, "installer: the web certificate(s) and key(s) handed over for the next front (values not logged)");
        }
        SecretOffer::new(values)
    }
}

/// Pure: the ONE monetize's words of a feed, as this front relays them.
pub fn relayed(feed: &BTreeMap<String, String>) -> BTreeMap<String, String> {
    RELAYED
        .iter()
        .filter_map(|k| {
            feed.get(*k)
                .map(|v| v.trim())
                .filter(|v| !v.is_empty())
                .map(|v| (k.to_string(), v.to_string()))
        })
        .collect()
}

#[cfg(test)]
mod relay_tests {
    use super::*;

    /// What the front relays is what a holger box's pull channel reads (`offer.<vm>.<key>`).
    #[test]
    fn the_one_monetizes_door_and_key_are_relayed_and_nothing_else() {
        let feed: BTreeMap<String, String> = [
            ("fleet", "https://monetize.ignalina.dk"),
            ("fleet_instance", "acme"),
            ("monetize_pubkey", "ab"),
            ("secret.shop_client_key", "never"),
        ]
        .iter()
        .map(|(k, v)| (k.to_string(), v.to_string()))
        .collect();
        let r = relayed(&feed);
        assert_eq!(
            r.keys().map(String::as_str).collect::<Vec<_>>(),
            ["fleet", "fleet_instance", "monetize_pubkey"],
            "never a secret"
        );
    }
}

#[cfg(test)]
mod clock_offer_tests {
    use super::*;

    /// **What holger's front offers is what a holger box's clock reads**: once
    /// the clock is started, `clock_key` + `clock_peer` ride the offer; relayed to
    /// a box as `offer.<vm>.<key>` they make a box configuration pinned to this
    /// front's time key at `<utility>:50123` (edda `estate-clock::offer`).
    /// ★ A7 row 14: the front offers its console key's public half, the same on every offer,
    /// minted once under its data root; no secret is in the offer.
    #[test]
    fn the_front_offers_its_console_keys_public_half() {
        let dir = tempfile::tempdir().unwrap();
        let front = HolgerFront::new(dir.path(), TunnelKind::None);
        let first = front.offer(None)[crate::console_key::OFFER_CONSOLE_PUBKEY].clone();
        assert_eq!(
            wg_appliance_core::keys::decode_key(&first)
                .map(|k| k.len())
                .ok(),
            Some(32),
            "{first}"
        );
        assert_eq!(
            front.offer(None)[crate::console_key::OFFER_CONSOLE_PUBKEY],
            first,
            "minted once, kept"
        );
        assert_eq!(
            HolgerFront::new(dir.path(), TunnelKind::None).offer(None)
                [crate::console_key::OFFER_CONSOLE_PUBKEY],
            first,
            "kept across boots"
        );
        assert!(front
            .offer(None)
            .keys()
            .all(|k| !k.contains("token") && !k.contains("private")));
    }

    #[test]
    fn the_started_clock_is_offered_and_a_box_parses_the_offer() {
        let dir = tempfile::tempdir().unwrap();
        let front = HolgerFront::new(dir.path(), TunnelKind::None);
        assert!(
            !front
                .offer(None)
                .contains_key(estate_clock::offer::OFFER_CLOCK_KEY),
            "no clock yet, nothing offered"
        );
        let (_, key_line) =
            estate_clock::front::ensure_key(&dir.path().join(estate_clock::offer::CLOCK_KEY_REL))
                .unwrap();
        let peer = estate_clock::offer::peer_spelling(std::net::Ipv4Addr::new(10, 13, 8, 1));
        front.clock_started(estate_clock::offer::ClockOffer {
            key_line: key_line.clone(),
            peer: Some(peer.clone()),
        });
        let offer = front.offer(None);
        assert_eq!(
            (offer["clock_key"].as_str(), offer["clock_peer"].as_str()),
            (key_line.as_str(), "peer://10.13.8.1:50123")
        );
        let relayed: BTreeMap<String, String> = offer
            .iter()
            .map(|(k, v)| (format!("offer.vm1.{k}"), v.clone()))
            .collect();
        match estate_clock::offer::box_config(&relayed) {
            Ok(estate_clock::config::Config::Sources {
                sources,
                peer_key: Some(k),
            }) => {
                assert_eq!(
                    sources,
                    vec![estate_clock::config::Source::Peer(
                        "10.13.8.1:50123".parse().unwrap()
                    )]
                );
                assert_eq!(
                    k.split_whitespace().nth(1),
                    key_line.split_whitespace().nth(1)
                );
            }
            other => panic!("a holger box must follow this front: {other:?}"),
        }
    }
}