nordisk-pairing 0.5.25

The boxes of an estate find each other: a seed each box is installed with, a one-time join against the front, a tunnel (WireGuard by default) they meet inside, roles decided once, a pairing loop each box runs itself, and the signal it reports. Shared by gunnar and holger; the laptop's xtask only lays machines, writes seeds and reads the signal. Design: .nornir/pairing-design.md (2026-09-24).
Documentation
//! **The kept installer material** (feature `kept`): what a SOLD estate keeps so that
//! its front can be the estate's installer after the sale.
//!
//! The shop's pairing leaves one bundle (`installer-material/1`, JSON: every file of the
//! order's material, the door's ledger, the plan it paired by). The estate keeps it in its own
//! primary's sealed slot; when `main` or `twin` is LOST, the front parses it here, mints a
//! fresh seed for the box the shop lays again, and re-admits that box over the installer's
//! link ([`readmit`], feature `installer-wireguard`). After an in-place upgrade it re-admits a
//! box that kept its address ([`readmit_upgraded`]).
//!
//! Product-neutral: Gunnar's and Holger's fronts read the same bundle, each naming its own
//! seed file (`seed.gunnar.toml`, `seed.holger.toml`).

use std::collections::BTreeMap;
use std::net::Ipv4Addr;
use std::sync::Mutex;
use std::time::Duration;

use crate::door::Door;
use crate::installer::{Material, Plan, Sink};
use crate::mtls::{InstallerCa, Issued};
use crate::seed::Seed;

/// The bundle's schema word (monetize-shop-pairing `BUNDLE_V`).
pub const BUNDLE_V: &str = "installer-material/1";
/// What a seed file is named in the bundle: `seed.<name>.toml`.
pub const SEED_FILE_PREFIX: &str = "seed.";
/// How long material minted from the kept CA lives (monetize-shop-pairing `MATERIAL_VALID`).
pub const MATERIAL_VALID: Duration = Duration::from_secs(180 * 24 * 3600);
/// A re-admission's budget: the box installs from its medium and a main restores from its twin.
pub const READMIT_BUDGET_S: u64 = 2 * 3600;

/// **The kept bundle**, as the slot holds it.
#[derive(Clone)]
pub struct Kept {
    pub files: BTreeMap<String, String>,
    pub ledger: String,
    pub plan: Plan,
}

impl std::fmt::Debug for Kept {
    /// Never a key.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Kept").field("files", &self.files.keys().collect::<Vec<_>>()).field("estate", &self.plan.estate).finish_non_exhaustive()
    }
}

/// The seed file of a box named `name` (`holger` → `seed.holger.toml`).
pub fn seed_file(name: &str) -> String {
    format!("{SEED_FILE_PREFIX}{name}.toml")
}

impl Kept {
    pub fn parse(bytes: &[u8]) -> Result<Kept, String> {
        let v: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| format!("the kept bundle is not JSON: {e}"))?;
        if v["v"] != BUNDLE_V {
            return Err(format!("the kept bundle is {:?}, not {BUNDLE_V}", v["v"]));
        }
        Ok(Kept {
            files: serde_json::from_value(v["files"].clone()).map_err(|e| format!("the kept files: {e}"))?,
            ledger: v["ledger"].as_str().ok_or("the kept bundle holds no ledger")?.to_string(),
            plan: serde_json::from_value(v["plan"].clone()).map_err(|e| format!("the kept plan: {e}"))?,
        })
    }

    pub fn render(&self) -> Result<Vec<u8>, String> {
        serde_json::to_vec(&serde_json::json!({"v": BUNDLE_V, "files": self.files, "ledger": self.ledger, "plan": self.plan})).map_err(|e| e.to_string())
    }

    fn file(&self, name: &str) -> Result<&str, String> {
        self.files.get(name).map(String::as_str).ok_or_else(|| format!("the kept bundle holds no {name}"))
    }

    /// The estate's installer CA.
    pub fn ca(&self) -> Result<InstallerCa, String> {
        Ok(InstallerCa { cert_pem: self.file(crate::installer::CA_CERT)?.to_string(), key_pem: self.file(crate::installer::CA_KEY)?.to_string() })
    }

    /// **The installer material** — the kept CA and link keys, and an installer certificate
    /// minted FRESH from the CA (the kept one may be near its end; the boxes trust the CA).
    pub fn material(&self) -> Result<Material, String> {
        let ca = self.ca()?;
        let me = ca.issue_installer(MATERIAL_VALID).map_err(|e| e.to_string())?;
        let link = Material::link_of(self.file(crate::installer::LINK_KEYS)?)?;
        Ok(Material { ca, me, link: Some(link) })
    }

    /// **A fresh seed for the box named `name`** (`seed.<name>.toml`): the kept one (its
    /// estate, CA, link keys), its bootstrap certificate minted anew from the CA — the kept one
    /// expires with the material.
    pub fn fresh_seed(&self, name: &str) -> Result<String, String> {
        let file = seed_file(name);
        let mut seed = Seed::parse(self.file(&file)?).map_err(|e| format!("the kept {file}: {e}"))?;
        let boot: Issued = self.ca()?.issue_bootstrap(MATERIAL_VALID).map_err(|e| e.to_string())?;
        seed.bootstrap_cert_pem = boot.cert_pem;
        seed.bootstrap_key_pem = boot.key_pem;
        seed.check().map_err(|e| e.to_string())?;
        seed.render().map_err(|e| e.to_string())
    }

    /// The door's ledger as the pairing left it.
    pub fn door(&self) -> Result<Door, String> {
        toml::from_str(&self.ledger).map_err(|e| format!("the kept ledger: {e}"))
    }
}

/// Pure: **the plan with `role` moved** to its new addresses, and the budget a re-admission
/// needs. The other boxes stand where they stood.
pub fn moved(plan: &Plan, role: &str, public: Ipv4Addr, utility: Ipv4Addr) -> Result<Plan, String> {
    let mut p = plan.clone();
    let vm = p.vms.iter_mut().find(|v| v.name == role).ok_or_else(|| format!("the kept plan names no {role}"))?;
    vm.public = public;
    vm.utility = utility;
    p.budget_s = p.budget_s.max(READMIT_BUDGET_S);
    p.check()?;
    Ok(p)
}

/// **The installer's sink, in memory**: the ledger as it moves (to go back into the slot); the
/// lines said go to `tracing`-free stderr through `say`. Nothing is written to disk.
#[derive(Default)]
pub struct MemSink {
    pub ledger: Mutex<Option<String>>,
}

impl Sink for MemSink {
    fn lines(&self, vm: &str, lines: &[String]) {
        for l in lines {
            eprintln!("readmit {vm}: {l}");
        }
    }
    fn status(&self, _vm: &str, _signal: Option<&crate::signal::Signal>, _last_line: &str) {}
    fn ledger(&self, door: &Door) {
        if let Ok(t) = toml::to_string(door) {
            *self.ledger.lock().unwrap_or_else(|p| p.into_inner()) = Some(t);
        }
    }
    fn say(&self, line: &str) {
        eprintln!("readmit: {line}");
    }
    fn secret_offer(&self, vm: &str, _offer: &crate::bootstrap::SecretOffer) -> Result<(), String> {
        // A front's secret offer stays with the front; a product box hands none.
        eprintln!("readmit {vm}: a secret offer (kept by its box)");
        Ok(())
    }
}

impl MemSink {
    fn take(&self) -> Result<String, String> {
        self.ledger.lock().unwrap_or_else(|p| p.into_inner()).clone().ok_or_else(|| "the re-admission left no ledger".to_string())
    }
}

/// How the front reaches one box as its installer.
#[cfg(feature = "installer-wireguard")]
pub type BoxPath = Box<dyn crate::installer_link::Path>;

/// **How the front reaches each box as their installer**: its own door direct (its link
/// address is its own), every other box over the installer's link to its UTILITY address.
#[cfg(feature = "installer-wireguard")]
pub fn paths_for(plan: &Plan, link: &crate::installer_link::LinkKeys) -> Result<Vec<(crate::installer::PlanVm, BoxPath)>, String> {
    use std::net::SocketAddrV4;
    let mut out: Vec<(crate::installer::PlanVm, BoxPath)> = Vec::new();
    for vm in &plan.vms {
        let path: BoxPath = if vm.kind == crate::seed::Kind::Front {
            Box::new(crate::installer_link::DirectPath { at: SocketAddrV4::new(crate::installer_link::LINK_VM, crate::bootstrap::PORT) })
        } else {
            Box::new(crate::installer_link::WgPath::new(SocketAddrV4::new(vm.utility, plan.link_port), link.clone())?)
        };
        out.push((vm.clone(), path));
    }
    Ok(out)
}

/// **Re-admit `role` at its new addresses** from the kept material: the plan moved, the door
/// as the pairing left it, the front the installer. `seed_name` names the seed the box was
/// laid with (`seed.<seed_name>.toml`, kept fresh in the bundle). Answers the bundle as it
/// stands after (the new ledger and plan, the fresh seed) and what was done.
#[cfg(feature = "installer-wireguard")]
pub fn readmit(kept: &Kept, role: &str, public: Ipv4Addr, utility: Ipv4Addr, seed_name: &str, seed: &str) -> Result<(Kept, String), String> {
    let m = kept.material()?;
    let plan = moved(&kept.plan, role, public, utility)?;
    let paths = paths_for(&plan, m.link.as_ref().ok_or("the kept material holds no link keys")?)?;
    let sink = MemSink::default();
    let (readmitted, outcome) = crate::installer::readmit(&plan, kept.door()?, &m, role, paths, &sink)?;
    let mut after = kept.clone();
    after.ledger = sink.take()?;
    after.plan = plan;
    after.files.insert(seed_file(seed_name), seed.to_string());
    let words = format!(
        "{role} re-admitted by the front: from {} to {} / {}{}; {} box(es) closed",
        readmitted.was.0,
        readmitted.now.0,
        readmitted.now.1,
        readmitted.recharge_from.as_deref().map(|f| format!(", told to fill from its twin at {f}")).unwrap_or_default(),
        outcome.vms.iter().filter(|v| v.closed).count()
    );
    Ok((after, words))
}

/// **The box `role` re-admitted after an in-place upgrade** ([`crate::installer::readmit_kept`]):
/// the plan as kept (the box stands where it stood), its data kept. Answers the bundle with the
/// ledger as it moved.
#[cfg(feature = "installer-wireguard")]
pub fn readmit_upgraded(kept: &Kept, role: &str) -> Result<(Kept, String), String> {
    let m = kept.material()?;
    let paths = paths_for(&kept.plan, m.link.as_ref().ok_or("the kept material holds no link keys")?)?;
    let sink = MemSink::default();
    let (_readmitted, outcome) = crate::installer::readmit_kept(&kept.plan, kept.door()?, &m, role, paths, &sink)?;
    let mut after = kept.clone();
    after.ledger = sink.take()?;
    Ok((after, format!("{role} re-admitted by the front after its upgrade (its data kept); {} box(es) closed", outcome.vms.iter().filter(|v| v.closed).count())))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::installer::PlanVm;
    use crate::seed::{Front, Kind, LinkSeed, TunnelKind};

    fn bundle() -> (Vec<u8>, InstallerCa) {
        let ca = InstallerCa::mint("acme.holger.example", MATERIAL_VALID).unwrap();
        let me = ca.issue_installer(MATERIAL_VALID).unwrap();
        let boot = ca.issue_bootstrap(MATERIAL_VALID).unwrap();
        let seed = Seed {
            estate: "acme.holger.example".into(),
            tunnel: TunnelKind::Wireguard,
            front: Front::default(),
            kind: Kind::Holger,
            installer_ca_pem: ca.cert_pem.clone(),
            bootstrap_cert_pem: boot.cert_pem.clone(),
            bootstrap_key_pem: boot.key_pem.clone(),
            peers_expected: 3,
            mullvad: None,
            tailscale: None,
            installer_link: Some(LinkSeed { installer_pubkey: "A".repeat(43) + "=", vm_private: "B".repeat(43) + "=", port: crate::installer_link::LINK_PORT }),
        };
        let vm = |name: &str, kind: Kind, last: u8| PlanVm { name: name.into(), public: Ipv4Addr::new(192, 0, 2, last), utility: Ipv4Addr::new(10, 13, 8, last), overlay: Ipv4Addr::new(10, 44, 0, last), kind, feed: BTreeMap::new() };
        let plan = Plan {
            estate: "acme.holger.example".into(),
            tunnel: TunnelKind::Wireguard,
            vms: vec![vm("front", Kind::Front, 1), vm("main", Kind::Holger, 2), vm("twin", Kind::Holger, 3)],
            feed: BTreeMap::new(),
            front: None,
            port: crate::bootstrap::PORT,
            link_port: crate::installer_link::LINK_PORT,
            budget_s: 600,
            close_when: Default::default(),
            box_cert_valid_s: None,
            front_waits_for: Vec::new(),
        };
        let files: BTreeMap<String, String> = [
            (crate::installer::CA_CERT.to_string(), ca.cert_pem.clone()),
            (crate::installer::CA_KEY.to_string(), ca.key_pem.clone()),
            (crate::installer::INSTALLER_CERT.to_string(), me.cert_pem),
            (crate::installer::INSTALLER_KEY.to_string(), me.key_pem),
            (crate::installer::LINK_KEYS.to_string(), Material::link_file(&("C".repeat(43) + "="), &("D".repeat(43) + "="))),
            (seed_file("holger"), seed.render().unwrap()),
        ]
        .into();
        (serde_json::to_vec(&serde_json::json!({"v": BUNDLE_V, "files": files, "ledger": "", "plan": plan})).unwrap(), ca)
    }

    /// ★ The bundle the shop's pairing leaves reads back; a fresh seed is the kept one with a
    /// NEW bootstrap certificate from the kept CA; the material mints a fresh installer
    /// certificate; a moved plan moves ONE box and gives the re-admission its budget.
    #[test]
    fn a_kept_bundle_gives_a_fresh_seed_fresh_material_and_a_moved_plan() {
        let (b, ca) = bundle();
        let k = Kept::parse(&b).expect("parses");
        assert!(!format!("{k:?}").contains("PRIVATE"), "never a key in Debug");
        let fresh = Seed::parse(&k.fresh_seed("holger").unwrap()).unwrap();
        let kept = Seed::parse(&k.files[&seed_file("holger")]).unwrap();
        assert_eq!((fresh.estate.as_str(), fresh.kind, fresh.installer_ca_pem.as_str()), (kept.estate.as_str(), Kind::Holger, ca.cert_pem.as_str()));
        assert_ne!(fresh.bootstrap_cert_pem, kept.bootstrap_cert_pem, "a NEW bootstrap certificate");
        assert!(k.fresh_seed("gunnar").unwrap_err().contains("seed.gunnar.toml"), "a seed the bundle does not hold is named");
        let m = k.material().unwrap();
        assert_ne!(m.me.cert_pem, k.files[crate::installer::INSTALLER_CERT], "a fresh installer certificate");
        assert!(m.link.is_some());
        let p = moved(&k.plan, "main", Ipv4Addr::new(192, 0, 2, 9), Ipv4Addr::new(10, 13, 8, 9)).unwrap();
        let main = p.vms.iter().find(|v| v.name == "main").unwrap();
        assert_eq!((main.public, main.utility), (Ipv4Addr::new(192, 0, 2, 9), Ipv4Addr::new(10, 13, 8, 9)));
        assert_eq!(p.vms.iter().find(|v| v.name == "twin").unwrap().utility, Ipv4Addr::new(10, 13, 8, 3), "the twin stands where it stood");
        assert_eq!(p.budget_s, READMIT_BUDGET_S);
        assert!(moved(&k.plan, "nobody", Ipv4Addr::LOCALHOST, Ipv4Addr::LOCALHOST).is_err());
        let again = Kept::parse(&k.render().unwrap()).unwrap();
        assert_eq!(again.files, k.files);
        assert!(Kept::parse(br#"{"v":"installer-material/0"}"#).unwrap_err().contains(BUNDLE_V));
    }
}