crates/gunnar-front/src/recharge.rs
//! **The front re-admits a lost box itself** (F4, the owner 2026-10-09): a SOLD estate keeps its
//! installer material in its own primary's sealed slot ([`crate::operator_door::INSTALLER_MATERIAL_SLOT`],
//! which the front TAKES from the shop after the sale — [`take_kept`], `SoldEstate.TakeMaterial`;
//! the shop never calls into the estate, ONE monetize G1), so when its `main` or
//! its `twin` is LOST the front is the estate's installer:
//!
//! 1. the shop does only the cloud half — `SoldEstate.Recharge` lays that ONE box again from the
//! release medium, with the seed the front hands it (the kept seed, its bootstrap certificate
//! minted fresh from the kept CA) as its `user_data`;
//! 2. the front re-admits it with nordisk-pairing's `readmit` over the installer's link —
//! to the box's UTILITY address (the front and its boxes share that network; a guest of the
//! UpCloud mock cannot reach a public address at all), its own door direct — and a recharged
//! `main` is filled from its twin (`recharge_from`);
//! 3. the new ledger and plan go back into the slot, and the shop is told `done` (its release
//! clone given back, its metadata off).
//!
//! The material is in this process only while it is used (it is never written to the front's
//! disk). [`watch`] decides WHEN: a box gone for [`GONE_FOR`] while the other gunnar box
//! answers; the shop refuses a box that still stands, so a link that merely broke lays nothing.
use std::collections::BTreeMap;
use std::net::Ipv4Addr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::shop_door::MATERIAL_VALID;
/// How the front reaches one box as its installer.
pub type BoxPath = Box<dyn nordisk_pairing::installer_link::Path>;
/// One box and whether its certificate was renewed.
pub type Renewed = (String, Result<bool, String>);
/// How long a box must be gone before the front asks the shop to lay it again.
pub const GONE_FOR: Duration = Duration::from_secs(180);
/// How often the front looks.
pub const LOOK_EVERY: Duration = Duration::from_secs(20);
/// After a refusal or a failed attempt, the next is no sooner than this.
pub const AGAIN_AFTER: Duration = Duration::from_secs(600);
/// **The kept bundle, its plan move, its paths and its re-admission are nordisk-pairing's**
/// (`kept`, A32-30: the same Holger's front uses; Gunnar's copy is gone). The installer's sink
/// says each line once (gunnar.rs 2026-10-25: 1.9 M nested `readmit:` lines).
pub use nordisk_pairing::kept::{moved, paths_for, readmit, Kept, MemSink, READMIT_BUDGET_S};
/// The seed name a Gunnar box is laid with (`seed.gunnar.toml` in the kept bundle).
pub const GUNNAR_SEED: &str = "gunnar";
/// Pure: **is it time to recharge `role`?** Gone for [`GONE_FOR`] while the OTHER gunnar box
/// answers (a whole estate unreachable is the front's own link, never two lost boxes), and no
/// attempt within [`AGAIN_AFTER`].
pub fn due(gone_for: Option<Duration>, other_answers: bool, last_try: Option<Duration>) -> bool {
gone_for.is_some_and(|g| g >= GONE_FOR)
&& other_answers
&& last_try.is_none_or(|t| t >= AGAIN_AFTER)
}
/// The kept bundle, held in this process while the primary lives (read from it, or as the
/// shop handed it over): a lost primary's slot is read from here — or from the twin.
static HELD: Mutex<Option<Vec<u8>>> = Mutex::new(None);
/// Hold the bundle (the door's slot verb calls this as it stores it).
pub fn hold(bytes: &[u8]) {
*HELD.lock().unwrap_or_else(|p| p.into_inner()) = Some(bytes.to_vec());
}
pub fn held() -> Option<Vec<u8>> {
HELD.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
/// **The fed shop door** (F4 under ONE monetize): the door, company, shop key and client-key
/// secret the front calls the ONE monetize IN through — fed on its own, with no monetize on the
/// estate (monetize-shop-pairing `contract::FEED_SHOP_DOOR`).
pub const FEED_SHOP_DOOR: &str = "shop_door.toml";
/// The product this front's calls to the ONE box are signed as on `Fleet`.
pub const PRODUCT: &str = "gunnar";
/// The shop's door from the fed [`FEED_SHOP_DOOR`], its client key from the fed
/// `secret.<client_key_secret>` beside it (a fed secret is a FEED line on this
/// front — nothing of it on disk, no monetize here to land it): `None` on an
/// estate that was not sold. The LEGACY fed `monetize.toml` fallback is gone
/// with the embedded monetize (K3, ONE monetize: the shop's pairing REFUSES a
/// sale feed that names `monetize.toml`).
pub fn shop_door(
feed: &BTreeMap<String, String>,
) -> Option<Result<crate::shop_door::ShopDoor, String>> {
let fed = feed.get(FEED_SHOP_DOOR)?;
Some((|| {
let t: toml::Table = toml::from_str(fed)
.map_err(|e| format!("the fed {FEED_SHOP_DOOR} is not TOML: {e}"))?;
let secret = t
.get("client_key_secret")
.and_then(|v| v.as_str())
.unwrap_or(crate::shop_door::CLIENT_KEY_SECRET);
let key = feed
.get(&format!("secret.{secret}"))
.ok_or_else(|| format!("the client key is not fed (`secret.{secret}`)"))?;
// P3-FLEET: the calls ride `Fleet.Ask` as product `gunnar` (a feed from before the fold
// names no product), and ask the old SoldEstate door once on a ONE box from before it.
Ok(crate::shop_door::ShopDoor::from_settings(&t, key)?
.with_seam(
feed.get(gunnar_server::feed::FEED_FLEET_AT)
.map(String::as_str),
feed.get(gunnar_server::feed::FEED_FLEET_CA)
.map(String::as_str),
)?
.or_over_fleet(PRODUCT))
})())
}
/// **The front takes its installer material from the shop** (F4, the pull of G1: the shop never
/// PUTs into an estate's slot): `take(false)` answers the bundle the shop's pairing kept for it;
/// it must read as a kept bundle, it is stored in the primary's slot (`store`) and held, and
/// only then is the shop told `take(true)` — after which its custody forgets it. Nothing stored:
/// the shop is not told, and the front asks again (the shop waits a day before it forgets).
pub fn take_kept(
take: &dyn Fn(bool) -> Result<(Vec<u8>, String), String>,
store: &dyn Fn(Vec<u8>) -> Result<(), String>,
) -> Result<String, String> {
let (bundle, words) = take(false)?;
let kept = Kept::parse(&bundle).map_err(|e| format!("the shop's installer material: {e}"))?;
store(bundle.clone()).map_err(|e| {
format!("the installer material could not be stored in the primary's slot: {e}")
})?;
hold(&bundle);
let (_, done) = take(true)?;
Ok(format!(
"{words}; the installer material of {} is in the primary's slot; {done}",
kept.plan.estate
))
}
/// Can `addr` be reached at all (a TCP connect)?
fn answers(addr: &str) -> bool {
use std::net::ToSocketAddrs as _;
addr.to_socket_addrs()
.ok()
.and_then(|mut a| a.next())
.is_some_and(|a| std::net::TcpStream::connect_timeout(&a, Duration::from_secs(5)).is_ok())
}
/// **One recharge of `role`**: the kept bundle (held, else the other box's slot), the shop
/// lays the box again with a fresh seed, the front re-admits it, the new bundle is stored, the
/// shop told done.
pub fn recharge(
root: &std::path::Path,
door: &crate::shop_door::ShopDoor,
role: &str,
other_control: &str,
primary: &dyn Fn() -> Option<String>,
rt: &tokio::runtime::Handle,
) -> Result<String, String> {
let bytes = match held() {
Some(b) => b,
None => rt.block_on(crate::operator_door::read_slot(
root,
other_control,
crate::operator_door::INSTALLER_MATERIAL_SLOT,
))?,
};
let kept = Kept::parse(&bytes)?;
let seed = kept.fresh_seed(GUNNAR_SEED)?;
let laid = door
.recharge_box(role, &seed, false)
.map_err(|e| format!("the shop: {e}"))?;
tracing::warn!(role, words = %laid.words, "recharge: the shop laid the box again; the front re-admits it");
let public: Ipv4Addr = laid
.public_ipv4
.parse()
.map_err(|e| format!("the shop's public address {:?}: {e}", laid.public_ipv4))?;
let utility: Ipv4Addr = laid
.utility_ipv4
.parse()
.map_err(|e| format!("the shop's utility address {:?}: {e}", laid.utility_ipv4))?;
let (after, words) = readmit(&kept, role, public, utility, GUNNAR_SEED, &seed)?;
let rendered = after.render()?;
hold(&rendered);
// The primary (as the re-feed names it now) keeps the new bundle.
let control = primary().ok_or("no primary is known after the re-admission")?;
if let Err(e) = rt.block_on(crate::operator_door::store_slot(
root,
&control,
crate::operator_door::INSTALLER_MATERIAL_SLOT,
rendered,
)) {
tracing::error!(error = %e, "recharge: the new ledger could not be stored in the primary's slot yet (held here; stored on the next look)");
}
let done = door
.recharge_box(role, "", true)
.map_err(|e| format!("the shop (done): {e}"))?;
Ok(format!("{words}; {}", done.words))
}
/// **The boxes `roles` re-admitted after an in-place upgrade, in ONE run**: the shared
/// nordisk-pairing `kept::readmit_together` (T16 / P4-REST: Gunnar's 2026-10-25 re-admission,
/// every product's front now), over this bundle as the shared reader takes it. Answers the
/// bundle with the ledger as it moved.
pub fn readmit_upgraded(kept: &Kept, roles: &[&str]) -> Result<(Kept, String), String> {
let (after, words) = nordisk_pairing::kept::readmit_together(kept, roles)?;
let mut back = kept.clone();
back.ledger = after.ledger;
Ok((back, words))
}
/// **ONE box upgraded from the release's keep medium** (the update's keep path, owner
/// 2026-10-14): the shop's `Upgrade` for `role`, the box re-admitted from the kept bundle, the
/// new ledger held and stored in the primary's slot, then `done`. Answers the words.
pub fn upgrade_one(
root: &std::path::Path,
door: &crate::shop_door::ShopDoor,
role: &str,
primary: &dyn Fn() -> Option<String>,
rt: &tokio::runtime::Handle,
) -> Result<String, String> {
let (upgraded, words) = door
.upgrade(role, false)
.map(|a| (a.upgraded, a.words))
.map_err(|e| format!("the shop: {e}"))?;
if !upgraded {
return Ok(words);
}
let back = readmit_boxes(root, &[role], primary, rt)?;
let done = door
.upgrade(role, true)
.map_err(|e| format!("the shop (done): {e}"))?;
Ok(format!("{words}; {back}; {}", done.words))
}
/// **Boxes re-admitted where they stand, from the kept bundle** ([`readmit_upgraded`]): the new
/// ledger held and stored in the primary's slot. The second half of [`upgrade_one`], and the
/// update's way back for boxes whose keep boot ran and was never re-admitted (2026-10-25). A
/// front that holds no bundle yet (just booted) reads it from either box's slot first.
pub fn readmit_boxes(
root: &std::path::Path,
roles: &[&str],
primary: &dyn Fn() -> Option<String>,
rt: &tokio::runtime::Handle,
) -> Result<String, String> {
let bytes = match held() {
Some(b) => b,
None => {
let at = |ip: Ipv4Addr| format!("{ip}:{}", crate::console::CONTROL_PORT);
let mut why = Vec::new();
let mut got = None;
for (name, control) in [
("main", at(nordisk_pairing::OVERLAY_BOX_A)),
("twin", at(nordisk_pairing::OVERLAY_BOX_B)),
] {
match rt.block_on(crate::operator_door::read_slot(
root,
&control,
crate::operator_door::INSTALLER_MATERIAL_SLOT,
)) {
Ok(b) => {
got = Some(b);
break;
}
Err(e) => why.push(format!("{name}: {e}")),
}
}
let b = got.ok_or_else(|| {
format!(
"no kept bundle is held yet, and no box's slot gave it ({})",
why.join("; ")
)
})?;
hold(&b);
b
}
};
let kept = Kept::parse(&bytes)?;
let (after, back) = readmit_upgraded(&kept, roles)?;
let rendered = after.render()?;
hold(&rendered);
if let Some(control) = primary() {
if let Err(e) = rt.block_on(crate::operator_door::store_slot(
root,
&control,
crate::operator_door::INSTALLER_MATERIAL_SLOT,
rendered,
)) {
tracing::error!(error = %e, "update: the new ledger could not be stored in the primary's slot yet (held here)");
}
}
Ok(back)
}
/// How often the front looks whether a box's own certificate is due ([`renew_boxes`]).
pub const RENEW_EVERY: Duration = Duration::from_secs(6 * 3600);
/// **Renew the boxes' own certificates that are due** (F4.2, nordisk-pairing 0.5.12): the front,
/// as the estate's installer from the kept material, reads each box's certificate over `paths`
/// and re-signs ITS key at two thirds of its life (`now_unix` injected), for the lifetime the
/// install gave them. Per box: renewed, not due, or what refused it.
pub fn renew_boxes(
kept: &Kept,
paths: Vec<(String, BoxPath)>,
now_unix: i64,
) -> Result<Vec<Renewed>, String> {
let m = kept.material()?;
let valid = kept
.plan
.box_cert_valid_s
.map(Duration::from_secs)
.unwrap_or(MATERIAL_VALID);
Ok(nordisk_pairing::installer::renew(
&m, paths, valid, now_unix,
))
}
/// **The installer CA renewed from the kept material before its end** (F4, the 180-day gap):
/// at two thirds of the CA certificate's life the front re-signs it with its own key — the same
/// key and name, a new lifetime — and answers the bundle to re-seal; `None` while it is not due.
pub fn renew_ca_due(kept: &Kept, now_unix: i64) -> Result<Option<Kept>, String> {
let ca = kept.ca()?;
if !nordisk_pairing::mtls::renewal_due(&ca.cert_pem, now_unix)
.map_err(|e| format!("the kept CA certificate: {e}"))?
{
return Ok(None);
}
let renewed = ca
.renew_ca(MATERIAL_VALID)
.map_err(|e| format!("the installer CA could not be renewed: {e}"))?;
let mut after = kept.clone();
after.files.insert(
nordisk_pairing::installer::CA_CERT.to_string(),
renewed.cert_pem,
);
Ok(Some(after))
}
/// **One renewal look over the kept bundle** (F4, the 180-day gaps): the installer CA renewed
/// first when due ([`renew_ca_due`]: the answer's bundle, to be held and stored back into the
/// primary's slot), then every box's own certificate from it ([`renew_all`]), each renewal
/// handing its box a fresh bootstrap for its next join (nordisk-pairing 0.5.13).
#[allow(clippy::type_complexity)]
pub fn renew_once(
kept: &Kept,
now_unix: i64,
) -> Result<(Option<Kept>, Vec<(String, Result<bool, String>)>), String> {
let ca_renewed = renew_ca_due(kept, now_unix)?;
let boxes = renew_all(ca_renewed.as_ref().unwrap_or(kept), now_unix)?;
Ok((ca_renewed, boxes))
}
/// [`renew_boxes`] for every box of the kept plan, on the installer's own paths.
pub fn renew_all(kept: &Kept, now_unix: i64) -> Result<Vec<Renewed>, String> {
let m = kept.material()?;
let paths = paths_for(
&kept.plan,
m.link
.as_ref()
.ok_or("the kept material holds no link keys")?,
)?;
renew_boxes(
kept,
paths.into_iter().map(|(vm, p)| (vm.name, p)).collect(),
now_unix,
)
}
/// **Watch the estate's two gunnar boxes, and recharge one that is lost.** As a jera job;
/// returns at once. Does nothing on an estate that was not sold (no fed [`FEED_SHOP_DOOR`]).
pub fn watch(vm: Arc<crate::vm::FrontVm>, rt: tokio::runtime::Handle) {
// A jera job (A28 M18, A32-14): listed, a look that panics is said and a FAILED job and the next one runs, stopped at the
// clean stop. Its first round only waits, as the thread's first sleep did.
let say: gunnar_common::jobs::Say = Arc::new(|l: &str| tracing::error!("{l}"));
let spawned = gunnar_common::jobs::start("front-recharge", LOOK_EVERY, say, {
let root = vm.root().to_path_buf();
let mut gone_since: BTreeMap<&'static str, Instant> = BTreeMap::new();
let mut last_try: BTreeMap<&'static str, Instant> = BTreeMap::new();
let mut refreshed: Option<Instant> = None;
let mut renew_look: Option<Instant> = None;
let mut take_look: Option<Instant> = None;
let mut first = true;
move || {
if std::mem::take(&mut first) {
return gunnar_common::jobs::Round::Again;
}
let Some(reply) = vm.latest() else {
return gunnar_common::jobs::Round::Again;
};
let Some(Ok(door)) = shop_door(&reply.feed) else {
return gunnar_common::jobs::Round::Again;
};
// The boxes' own certificates, renewed from the kept material at 2/3 of their life.
if let Some(b) =
held().filter(|_| renew_look.is_none_or(|r| r.elapsed() >= RENEW_EVERY))
{
renew_look = Some(Instant::now());
match Kept::parse(&b)
.and_then(|k| renew_once(&k, nordisk_pairing::mtls::now_unix()))
{
Ok((ca_renewed, each)) => {
if let Some(after) = ca_renewed {
tracing::warn!("renew: the installer CA certificate was renewed (same key and name, a new lifetime); stored back into the slot");
match after.render() {
Ok(rendered) => {
hold(&rendered);
let control = crate::console::target(&reply)
.map(|t| t.control)
.unwrap_or_else(|| {
format!(
"{}:{}",
nordisk_pairing::OVERLAY_BOX_A,
crate::console::CONTROL_PORT
)
});
if let Err(e) = rt.block_on(crate::operator_door::store_slot(
&root,
&control,
crate::operator_door::INSTALLER_MATERIAL_SLOT,
rendered,
)) {
tracing::error!(error = %e, "renew: the renewed CA could not be stored in the primary's slot yet (held here; renewed again on the next look)");
}
}
Err(e) => {
tracing::error!(error = %e, "renew: the renewed bundle would not render")
}
}
}
each.iter().for_each(|(name, r)| match r {
Ok(true) => tracing::warn!(box_ = %name, "renew: the box's own certificate was renewed (same key, a new lifetime)"),
Ok(false) => tracing::debug!(box_ = %name, "renew: not due"),
Err(e) => tracing::error!(box_ = %name, error = %e, "renew: not renewed; looked at again later"),
})
}
Err(e) => tracing::error!(error = %e, "renew: the kept material cannot renew"),
}
}
let at = |ip: Ipv4Addr| format!("{ip}:{}", crate::console::CONTROL_PORT);
let (main_at, twin_at) = (
at(nordisk_pairing::OVERLAY_BOX_A),
at(nordisk_pairing::OVERLAY_BOX_B),
);
let primary_now = crate::console::target(&reply)
.map(|t| t.control)
.unwrap_or_else(|| main_at.clone());
let (main_up, twin_up) = (answers(&main_at), answers(&twin_at));
// While the primary lives, the bundle is held fresh from its slot.
if answers(&primary_now)
&& refreshed.is_none_or(|r| r.elapsed() >= Duration::from_secs(600))
{
if let Ok(b) = rt.block_on(crate::operator_door::read_slot(
&root,
&primary_now,
crate::operator_door::INSTALLER_MATERIAL_SLOT,
)) {
hold(&b);
refreshed = Some(Instant::now());
}
}
// No material in the slot yet: the front takes it from the shop (it never pushes).
if held().is_none()
&& answers(&primary_now)
&& take_look.is_none_or(|t| t.elapsed() >= AGAIN_AFTER)
{
take_look = Some(Instant::now());
let take = |done: bool| {
door.take_material(done)
.map(|a| (a.bundle, a.words))
.map_err(|e| format!("the shop: {e}"))
};
let store = |b: Vec<u8>| {
rt.block_on(crate::operator_door::store_slot(
&root,
&primary_now,
crate::operator_door::INSTALLER_MATERIAL_SLOT,
b,
))
.map(|_| ())
};
match take_kept(&take, &store) {
Ok(words) => tracing::warn!("material: {words}"),
Err(e) => {
tracing::error!(error = %e, "material: not taken yet; asked again later")
}
}
}
for (role, up, other_up, other_at) in [
("main", main_up, twin_up, twin_at.clone()),
("twin", twin_up, main_up, main_at.clone()),
] {
// A box the ONE box's update is working on (its keep boot, its re-exec) is down, not lost.
if up || crate::update::busy(Some(role)) {
gone_since.remove(role);
continue;
}
let since = *gone_since.entry(role).or_insert_with(Instant::now);
if !due(
Some(since.elapsed()),
other_up,
last_try.get(role).map(|t| t.elapsed()),
) {
continue;
}
last_try.insert(role, Instant::now());
tracing::warn!(
role,
gone_s = since.elapsed().as_secs(),
"recharge: the box is gone; the front asks the shop to lay it again"
);
let vm_now = Arc::clone(&vm);
let primary = move || {
vm_now
.latest()
.as_ref()
.and_then(crate::console::target)
.map(|t| t.control)
};
match recharge(&root, &door, role, &other_at, &primary, &rt) {
Ok(words) => {
tracing::warn!(role, "recharge: {words}");
gone_since.remove(role);
}
Err(e) => {
tracing::error!(role, error = %e, "recharge: not done; asked again later")
}
}
}
gunnar_common::jobs::Round::Again
}
});
if let Err(e) = spawned {
tracing::error!(error = %e, "recharge: no thread to watch the boxes");
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use nordisk_pairing::door::Door;
use nordisk_pairing::installer::Plan;
use nordisk_pairing::seed::{Kind, Seed};
pub(crate) fn order() -> monetize_server::estate_order::LaidOrder {
serde_json::from_value(serde_json::json!({
"company": "acme", "apex": "test2.gunnar.rs", "domain": "",
"names": ["acme.test2.gunnar.rs", "www.acme.test2.gunnar.rs", "git.acme.test2.gunnar.rs"],
"estate": {"reference": "estate/acme", "company": "acme", "zone": "se-sto1", "machines": [
{"role": "front", "hostname": "f", "title": "f", "plan": "STARTER-4xCPU-8GB", "server": "s1", "public_ipv4": "198.51.100.1", "utility_ipv4": "10.6.0.1", "disks": [{"title": "sys", "id": "d1", "gib": 40, "tier": "standard", "address": "virtio:0"}]},
{"role": "main", "hostname": "m", "title": "m", "plan": "STARTER-4xCPU-16GB", "server": "s2", "public_ipv4": "198.51.100.2", "utility_ipv4": "10.6.0.2", "disks": [{"title": "sys", "id": "d2", "gib": 50, "tier": "standard", "address": "virtio:0"}]},
{"role": "twin", "hostname": "t", "title": "t", "plan": "STARTER-1xCPU-2GB", "server": "s3", "public_ipv4": "198.51.100.3", "utility_ipv4": "10.6.0.3", "disks": [{"title": "sys", "id": "d3", "gib": 20, "tier": "standard", "address": "virtio:0"}]}
]},
"first_login_ttl_secs": 604800
}))
.unwrap()
}
pub(crate) fn kept() -> Kept {
// The shop's own pairing mints the fixture (dev-dependencies: nothing of it ships).
let gunnar =
monetize_shop_pairing::ProductContract::of(&gunnar_monetize_guest::lifecycle::facts())
.unwrap();
let files: BTreeMap<String, String> =
monetize_shop_pairing::mint_material_for(&gunnar, "acme.test2.gunnar.rs")
.unwrap()
.into_iter()
.collect();
let plan = monetize_shop_pairing::plan_of_for(
&gunnar,
&order(),
BTreeMap::new(),
BTreeMap::new(),
&BTreeMap::new(),
600,
)
.unwrap();
// The kept link keys are the ones the seeds name (mint_material wrote both).
Kept {
files,
ledger: toml::to_string(&Door::default()).unwrap(),
plan,
}
}
/// ★ **The kept bundle reads back, and the front can be the estate's installer from it**:
/// its CA, a FRESH installer certificate, the link keys; a fresh seed whose bootstrap is new
/// and whose link key is the kept one.
#[test]
fn the_kept_bundle_makes_the_front_the_estates_installer() {
let k = kept();
let back = Kept::parse(&k.render().unwrap()).unwrap();
assert_eq!(back.plan, k.plan);
let m = back.material().unwrap();
assert_eq!(m.ca.cert_pem, k.files["ca.cert.pem"]);
assert_ne!(
m.me.cert_pem, k.files["installer.cert.pem"],
"the installer certificate is minted fresh"
);
assert!(m.link.is_some());
let fresh = Seed::parse(&back.fresh_seed(GUNNAR_SEED).unwrap()).unwrap();
let old = Seed::parse(&k.files["seed.gunnar.toml"]).unwrap();
assert_ne!(
fresh.bootstrap_cert_pem, old.bootstrap_cert_pem,
"a new bootstrap certificate"
);
assert_eq!(
fresh.installer_link.as_ref().map(|l| l.vm_private.clone()),
old.installer_link.as_ref().map(|l| l.vm_private.clone()),
"the kept link key"
);
assert_eq!(
(fresh.estate.as_str(), fresh.kind),
(old.estate.as_str(), Kind::Gunnar)
);
assert!(Kept::parse(b"{\"v\":\"other\"}").is_err());
}
/// ★ The plan moves ONE box, and a re-admission gets the time a restore needs.
#[test]
fn the_plan_moves_one_box_and_the_others_stand() {
let k = kept();
let p = moved(
&k.plan,
"main",
"203.0.113.7".parse().unwrap(),
"10.6.0.9".parse().unwrap(),
)
.unwrap();
let get = |p: &Plan, n: &str| p.vms.iter().find(|v| v.name == n).unwrap().clone();
assert_eq!(
(get(&p, "main").public, get(&p, "main").utility),
("203.0.113.7".parse().unwrap(), "10.6.0.9".parse().unwrap())
);
assert_eq!(get(&p, "twin"), get(&k.plan, "twin"), "twin stands");
// The front stands where it stood; the main laid again gets a NEW pre-shared key and
// the front the same one (nordisk-pairing `kept::moved`, lane WG-PSK).
let psk = get(&p, "main")
.feed
.get(nordisk_pairing::installer::FEED_WG_PSK)
.cloned()
.expect("the main laid again is fed a PSK");
let mut front = get(&p, "front");
assert_eq!(
front
.feed
.remove(&nordisk_pairing::installer::feed_wg_psk_for("main")),
Some(psk),
"the front holds the same one"
);
assert_eq!(front, get(&k.plan, "front"), "front stands");
assert!(p.budget_s >= READMIT_BUDGET_S);
assert!(moved(&k.plan, "nobody", Ipv4Addr::LOCALHOST, Ipv4Addr::LOCALHOST).is_err());
}
/// ★ **When the front recharges**: a box gone long enough while the other answers, once per
/// [`AGAIN_AFTER`] — never when both are silent (that is the front's own link).
#[test]
fn a_box_is_recharged_only_when_gone_long_enough_and_the_other_answers() {
let g = |s: u64| Some(Duration::from_secs(s));
assert!(due(g(GONE_FOR.as_secs()), true, None));
assert!(!due(g(GONE_FOR.as_secs() - 1), true, None), "not yet");
assert!(
!due(g(3600), false, None),
"both silent: the front's own link, not two lost boxes"
);
assert!(!due(None, true, None), "it answers");
assert!(!due(g(3600), true, g(60)), "tried a minute ago");
assert!(due(g(3600), true, Some(AGAIN_AFTER)));
}
/// The shop's door is the fed `shop_door.toml`, its client key the fed secret beside it;
/// an estate that was not sold has none, and nothing of it touches the front's disk.
#[test]
fn the_shop_door_is_the_fed_shop_door_toml_and_its_key_the_fed_secret() {
let shop_key: String = monetize::sign::Signer::from_bytes(&[7; 32])
.public_key()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let own = format!("door = \"https://monetize.ignalina.dk\"\ncompany = \"acme\"\nshop_pubkey = \"{shop_key}\"\nclient_key_secret = \"shop_client_key\"\n");
let feed = BTreeMap::from([
(FEED_SHOP_DOOR.to_string(), own.clone()),
("secret.shop_client_key".to_string(), "11".repeat(32)),
]);
let door = shop_door(&feed).expect("a sold estate").expect("the door");
assert_eq!(
(door.door.as_str(), door.company.as_str()),
("https://monetize.ignalina.dk", "acme")
);
assert_eq!(door.shop_host(), "ignalina.dk");
assert!(shop_door(&BTreeMap::new()).is_none(), "not sold: no shop");
// ★ K3 under ONE monetize: a LEGACY fed monetize.toml raises no door — the front runs
// no monetize to read it (the shop's pairing refuses such a feed at the sale).
let legacy = BTreeMap::from([("monetize.toml".to_string(), format!("[clouds.shop]\ndoor = \"http://10.0.2.100:39963\"\ncompany = \"acme\"\nshop_pubkey = \"{shop_key}\"\n"))]);
assert!(
shop_door(&legacy).is_none(),
"a legacy monetize.toml is not a shop door"
);
let keyless = BTreeMap::from([(FEED_SHOP_DOOR.to_string(), own)]);
let e = shop_door(&keyless).unwrap().unwrap_err();
assert!(
e.contains("secret.shop_client_key"),
"the missing fed key is said: {e}"
);
let bad = BTreeMap::from([(FEED_SHOP_DOOR.to_string(), "door = ".to_string())]);
assert!(
shop_door(&bad).unwrap().is_err(),
"a broken door is said, not taken for unsold"
);
}
/// ★ **F4 (E1 as a pull): the front TAKES its installer material from the shop** — the
/// bundle is stored in the primary's slot and held, and only THEN is the shop told done; a
/// slot that cannot store it, or a bundle that is not one, tells the shop nothing.
#[test]
fn the_front_takes_its_installer_material_and_tells_the_shop_only_once_it_is_stored() {
let bundle = kept().render().unwrap();
let calls = Mutex::new(Vec::<bool>::new());
let take = |done: bool| -> Result<(Vec<u8>, String), String> {
calls.lock().unwrap().push(done);
Ok((
if done { Vec::new() } else { bundle.clone() },
if done {
"the shop forgets it".into()
} else {
"handed".into()
},
))
};
let stored = Mutex::new(None::<Vec<u8>>);
let words = take_kept(&take, &|b| {
*stored.lock().unwrap() = Some(b);
Ok(())
})
.unwrap();
assert_eq!(
*calls.lock().unwrap(),
vec![false, true],
"taken, then done"
);
assert_eq!(
stored.lock().unwrap().as_deref(),
Some(bundle.as_slice()),
"stored as handed"
);
assert_eq!(held().as_deref(), Some(bundle.as_slice()), "and held");
assert!(
words.contains("acme.test2.gunnar.rs") && words.contains("the shop forgets it"),
"{words}"
);
calls.lock().unwrap().clear();
let e = take_kept(&take, &|_| Err("the primary is away".into())).unwrap_err();
assert!(e.contains("the primary is away"), "{e}");
assert_eq!(
*calls.lock().unwrap(),
vec![false],
"not stored: the shop is not told done"
);
calls.lock().unwrap().clear();
let junk = |done: bool| -> Result<(Vec<u8>, String), String> {
calls.lock().unwrap().push(done);
Ok((b"{}".to_vec(), String::new()))
};
assert!(take_kept(&junk, &|_| panic!("never stored"))
.unwrap_err()
.contains("the shop's installer material"));
assert_eq!(*calls.lock().unwrap(), vec![false]);
}
/// ★ **F4.2: the front renews a box's own certificate from the kept material at two thirds
/// of its life** — a closed box serving its log door, a clock advanced past 2/3: renewed, the
/// same key and name, kept on the box; not before.
#[test]
fn a_box_certificate_is_renewed_by_the_front_from_the_kept_material_at_two_thirds() {
use nordisk_pairing::bootstrap::Vm as _;
let k = kept();
let ca = k.ca().unwrap();
let boot = ca.issue_bootstrap(Duration::from_secs(3600)).unwrap();
let dir = tempfile::tempdir().unwrap();
let seed = Seed {
estate: k.plan.estate.clone(),
tunnel: nordisk_pairing::seed::TunnelKind::Wireguard,
front: nordisk_pairing::seed::Front::default(),
kind: Kind::Front,
installer_ca_pem: ca.cert_pem.clone(),
bootstrap_cert_pem: boot.cert_pem,
bootstrap_key_pem: boot.key_pem,
peers_expected: 3,
mullvad: None,
tailscale: None,
installer_link: Some(nordisk_pairing::seed::LinkSeed {
installer_pubkey: format!("{}=", "A".repeat(43)),
vm_private: format!("{}=", "B".repeat(43)),
port: nordisk_pairing::installer_link::LINK_PORT,
psk: None,
}),
};
let vm = Arc::new(crate::vm::FrontVm::open(dir.path(), seed).unwrap());
let csr = vm.hello().unwrap().csr_pem;
let first = ca.sign_box(&csr, "front", MATERIAL_VALID).unwrap();
gunnar_server::pairing::write_secret(
dir.path(),
gunnar_server::pairing::OWN_CERT_REL,
&first,
)
.unwrap();
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let at = match l.local_addr().unwrap() {
std::net::SocketAddr::V4(a) => a,
_ => unreachable!(),
};
let (v, ca_pem) = (Arc::clone(&vm), ca.cert_pem.clone());
std::thread::spawn(move || {
nordisk_pairing::bootstrap::serve_log_door(
&*v,
&nordisk_pairing::bootlog::BootLog::default(),
&ca_pem,
&l,
&|_| {},
)
});
let path = || {
vec![(
"front".to_string(),
Box::new(nordisk_pairing::installer_link::DirectPath { at })
as Box<dyn nordisk_pairing::installer_link::Path>,
)]
};
let now = nordisk_pairing::mtls::now_unix();
let r = renew_boxes(&k, path(), now).unwrap();
assert!(matches!(r[0].1, Ok(false)), "fresh, not due: {:?}", r[0].1);
assert_eq!(vm.identity().cert_pem, first);
let r = renew_boxes(&k, path(), now + 121 * 24 * 3600).unwrap();
assert!(
matches!(r[0].1, Ok(true)),
"day 121 of 180: renewed: {:?}",
r[0].1
);
let kept_now = vm.identity().cert_pem;
assert_ne!(kept_now, first, "the box keeps the renewed certificate");
assert_eq!(nordisk_pairing::mtls::cert_cn(&kept_now).unwrap(), "front");
assert!(
nordisk_pairing::mtls::renewal_refusal(&first, &kept_now).is_none(),
"the same key and name"
);
// ★ The renewal handed the front a fresh bootstrap for its next join, kept in its seed.
let on_disk = Seed::parse(
&std::fs::read_to_string(dir.path().join(gunnar_server::pairing::SEED_REL)).unwrap(),
)
.unwrap();
assert_ne!(on_disk.bootstrap_cert_pem, vm.identity().cert_pem);
assert_eq!(
nordisk_pairing::mtls::cert_cn(&on_disk.bootstrap_cert_pem).unwrap(),
nordisk_pairing::mtls::BOOTSTRAP_CN,
"a bootstrap pair"
);
assert!(
nordisk_pairing::mtls::cert_left(&on_disk.bootstrap_cert_pem, now).unwrap()
> Duration::from_secs(3600),
"a fresh lifetime, not the medium's hour"
);
}
/// ★ **The front renews the installer CA from the kept material before its end** (the
/// 180-day gap): not due when fresh; at day 121 of 180 a bundle whose CA certificate is
/// renewed — the same key, the same name, a later end — and nothing else changed.
#[test]
fn the_installer_ca_is_renewed_from_the_kept_material_before_its_end() {
let k = kept();
let now = nordisk_pairing::mtls::now_unix();
assert!(renew_ca_due(&k, now).unwrap().is_none(), "fresh: not due");
let after = renew_ca_due(&k, now + 121 * 24 * 3600)
.unwrap()
.expect("due at day 121 of 180");
let (old, new) = (k.ca().unwrap(), after.ca().unwrap());
assert_eq!(new.key_pem, old.key_pem, "the same key");
assert_ne!(new.cert_pem, old.cert_pem);
assert_eq!(
nordisk_pairing::mtls::cert_cn(&new.cert_pem).unwrap(),
nordisk_pairing::mtls::cert_cn(&old.cert_pem).unwrap()
);
assert_eq!(
(after.ledger.as_str(), &after.plan),
(k.ledger.as_str(), &k.plan),
"nothing else changed"
);
assert_eq!(after.files.len(), k.files.len());
// At day 121 for real: a CA certificate minted 121 days ago is due NOW and renews to a later end.
let mut aged = k.clone();
let key = rcgen::KeyPair::from_pem(&old.key_pem).unwrap();
// The CA's own shape (nordisk-pairing's `mint`), moved 121 days back.
let mut p = rcgen::CertificateParams::default();
p.distinguished_name.push(
rcgen::DnType::CommonName,
nordisk_pairing::mtls::cert_cn(&old.cert_pem).unwrap(),
);
p.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Constrained(0));
p.key_usages = vec![
rcgen::KeyUsagePurpose::KeyCertSign,
rcgen::KeyUsagePurpose::DigitalSignature,
];
let back = Duration::from_secs(121 * 24 * 3600);
let start = time::OffsetDateTime::now_utc();
let left = nordisk_pairing::mtls::cert_left(&old.cert_pem, start.unix_timestamp()).unwrap();
(p.not_before, p.not_after) = (start - back, start + left - back);
aged.files.insert(
nordisk_pairing::installer::CA_CERT.to_string(),
p.self_signed(&key).unwrap().pem(),
);
let renewed = renew_ca_due(&aged, now).unwrap().expect("due now");
let (was, is) = (aged.ca().unwrap(), renewed.ca().unwrap());
assert!(
nordisk_pairing::mtls::cert_left(&is.cert_pem, now).unwrap()
> nordisk_pairing::mtls::cert_left(&was.cert_pem, now).unwrap()
+ Duration::from_secs(100 * 24 * 3600),
"a later end"
);
assert_eq!(is.key_pem, was.key_pem);
}
}