crates/gunnar-front/src/vm.rs
//! **The installer's view of vm1** — edda `nordisk-pairing`'s [`Vm`], the
//! front's side of it.
//!
//! The installer runs on the operator's machine and connects OUT to this
//! box's bootstrap listener over its WireGuard link, under mutual TLS
//! (gunnar-server's `installer_listener::serve_installer`, the one listener
//! both media run). This box presents kind **`front`** — the door admits a
//! front only on the FRONT slot, so this medium laid on a box slot is refused
//! there (`[join-forged]`) — and it is fed its name, overlay address, its own
//! certificate, role `front` and the peers. Every feed is kept in
//! `<data>/pairing/joined.toml`, so a reboot resumes as itself.
//!
//! PAIRED is the front's own reading: once its relay is up and the fed reply
//! lists every box of the estate (`peers_expected` in the seed — front, main
//! and twin).
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use nordisk_pairing::bootstrap::Vm;
use nordisk_pairing::join::{JoinReply, JoinRequest};
use nordisk_pairing::mtls::{self, Issued};
use nordisk_pairing::pair::{Event, Machine, State};
use nordisk_pairing::seed::{Kind, Seed, TunnelKind};
use nordisk_pairing::signal::Signal;
use nordisk_pairing::Role;
use gunnar_server::installer_listener::CLOSED_REL;
use gunnar_server::pairing::{JOINED_REL, OWN_CERT_REL, OWN_KEY_REL, SIGNAL_REL};
/// **vm1, as the installer sees it.**
pub struct FrontVm {
root: PathBuf,
seed: Seed,
own_key: String,
fed: Mutex<Option<JoinReply>>,
machine: Mutex<Machine>,
relay_up: AtomicBool,
clock: Mutex<Option<crate::clock::ClockOffer>>,
}
/// Pure: whether a feed may be taken by the FRONT medium. Only role `front`:
/// anything else means the installer laid this medium where a gunnar box
/// belongs, and taking it would make vm1 half of a pair.
pub fn refuses(reply: &JoinReply) -> Option<String> {
(reply.role != Some(Role::Front)).then(|| {
format!(
"REFUSED [front-medium-is-only-the-front] the installer fed `{}` role {:?}; this VM booted the FRONT medium (`gunnar-front-installer-open`), which is vm1 and nothing else. Lay main and twin with the gunnar medium",
reply.me.name, reply.role
)
})
}
impl FrontVm {
/// This box's own key (minted once, 0600) and what was fed before.
pub fn open(root: &Path, seed: Seed) -> anyhow::Result<FrontVm> {
let own_key = match std::fs::read_to_string(root.join(OWN_KEY_REL)) {
Ok(k) if !k.trim().is_empty() => k,
_ => {
let (_, key) = mtls::box_request().map_err(|e| anyhow::anyhow!("{e}"))?;
gunnar_server::pairing::write_secret(root, OWN_KEY_REL, &key)?;
key
}
};
let fed = gunnar_server::fed_at_rest::read(root, JOINED_REL)
.and_then(|t| JoinReply::parse(&t).ok())
.filter(|r| r.estate == seed.estate && refuses(r).is_none());
let vm = FrontVm {
root: root.to_path_buf(),
seed,
own_key,
fed: Mutex::new(fed),
machine: Mutex::new(Machine::default()),
relay_up: AtomicBool::new(false),
clock: Mutex::new(None),
};
vm.advance();
Ok(vm)
}
pub fn root(&self) -> &Path {
&self.root
}
/// What the installer fed last.
pub fn latest(&self) -> Option<JoinReply> {
self.fed.lock().ok().and_then(|f| f.clone())
}
/// **Replace one fed value** — the operator door's rotation (h-admin-token):
/// the kept reply and `<data>/pairing/joined.toml` (sealed) both, so the
/// console takes it on its next look. `Ok(true)` when it changed.
pub fn rotate(&self, key: &str, value: &str) -> Result<bool, String> {
let mut fed = self.fed.lock().map_err(|e| e.to_string())?;
let reply = fed.as_mut().ok_or_else(|| {
"REFUSED [feed-rotate-unfed] this front was never fed: there is nothing to rotate"
.to_string()
})?;
if reply.feed.get(key).map(String::as_str) == Some(value) {
return Ok(false);
}
let mut next = reply.clone();
next.feed.insert(key.to_string(), value.to_string());
let text = next.render().map_err(|e| e.to_string())?;
gunnar_server::fed_at_rest::write(&self.root, JOINED_REL, &text)
.map_err(|e| format!("{e:#}"))?;
*reply = next;
Ok(true)
}
/// Whether the installer closed this box's INSTALL on this installation
/// (the log door stays, `installer_listener::Door::LogOnly`).
pub fn closed(&self) -> bool {
self.root.join(CLOSED_REL).exists()
}
/// The clock started: its key and peer are offered from now on.
pub fn clock_started(&self, offer: crate::clock::ClockOffer) {
if let Ok(mut c) = self.clock.lock() {
*c = Some(offer);
}
}
/// The relay is up ([`crate::role`]): the front's "tunnel".
pub fn relay_started(&self) {
self.relay_up.store(true, Ordering::SeqCst);
self.advance();
}
/// **Walk the pairing machine** from what is known now — the fed reply and
/// whether the relay is up — and write the signal when it moved.
pub fn advance(&self) {
let Some(reply) = self.latest() else { return };
let Ok(mut m) = self.machine.lock() else {
return;
};
let before = m.clone();
let joined = || Event::Joined {
role: Role::Front,
primary: None,
peers_admitted: reply.peers.len().min(u8::MAX as usize) as u8,
peers_expected: self.seed.peers_expected,
};
if m.state == State::Booted {
m.apply(joined());
}
if matches!(m.state, State::Joined { .. }) && self.relay_up.load(Ordering::SeqCst) {
m.apply(Event::TunnelUp);
}
if matches!(m.state, State::RoleKnown { .. }) {
m.apply(joined());
}
if *m != before {
let s = Signal::of(&reply.me.name, self.seed.tunnel, &m);
tracing::info!("{}", s.line().trim());
if let Some(text) = s.render() {
let _ = gunnar_server::durable::write_atomic_unframed(
&self.root,
SIGNAL_REL,
text.as_bytes(),
);
}
}
}
fn own_cert(&self) -> Option<String> {
std::fs::read_to_string(self.root.join(OWN_CERT_REL))
.ok()
.filter(|c| c.contains("BEGIN CERTIFICATE"))
}
}
impl Vm for FrontVm {
/// Live update (owner 2026-10-12): what this front runs.
#[cfg(target_os = "linux")]
fn live_running(&self) -> Result<nordisk_pairing::live::Running, String> {
gunnar_server::live_update::running()
}
/// Live update: a new app for the slot not running, then the graceful re-exec.
#[cfg(target_os = "linux")]
fn live_stage(&self, stage: &nordisk_pairing::live::Stage) -> Result<String, String> {
gunnar_server::live_update::stage(stage)
}
fn hello(&self) -> Result<JoinRequest, String> {
let csr = if self.own_cert().is_some() {
String::new()
} else {
mtls::box_request_for(&self.own_key)
.map_err(|e| e.to_string())?
.0
};
Ok(JoinRequest {
estate: self.seed.estate.clone(),
kind: Kind::Front,
// The relay's key: the door derives the `front` peer every box
// dials from it (edda `Door::front_tunnel`).
tunnel_pubkey: if self.seed.tunnel == TunnelKind::Wireguard {
gunnar_server::pairing::tunnel_pubkey(&self.root)
} else {
String::new()
},
// The election compares the two gunnar boxes only.
capacity: None,
csr_pem: csr,
})
}
fn fed(&self, reply: &JoinReply) -> Result<(), String> {
if reply.estate != self.seed.estate {
return Err(format!(
"REFUSED [installer-estate] this VM is of `{}`, the installer fed `{}`",
self.seed.estate, reply.estate
));
}
if let Some(why) = refuses(reply) {
return Err(why);
}
if let Some(cert) = &reply.cert_pem {
gunnar_server::pairing::write_secret(&self.root, OWN_CERT_REL, cert)
.map_err(|e| format!("{e:#}"))?;
}
let text = reply.render().map_err(|e| e.to_string())?;
gunnar_server::fed_at_rest::write(&self.root, JOINED_REL, &text)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(name = %reply.me.name, overlay = %reply.me.overlay, peers = reply.peers.len(), "installer: fed the FRONT role — kept in <data>/pairing/joined.toml");
*self.fed.lock().map_err(|e| e.to_string())? = Some(reply.clone());
self.advance();
Ok(())
}
/// **A later answer to this CLOSED front** (nordisk-pairing 0.5.8,
/// `installer::readmit`): a box of the estate died and was laid again at a
/// new address with a new tunnel key. Kept like a feed — the role thread
/// ([`crate::role`]) re-keys the relay and certifies the moved pair from it
/// — and never a change of what this VM is (`refeed_refusal`).
fn refed(&self, reply: &JoinReply) -> Result<(), String> {
if let Some(why) = nordisk_pairing::bootstrap::refeed_refusal(self.latest().as_ref(), reply)
{
return Err(why);
}
if refeed_said_loud(std::time::Instant::now()) {
tracing::warn!(peers = reply.peers.len(), primary = ?reply.primary_utility, "installer: RE-FED — a box of this estate was laid again; the relay and the routes follow it");
} else {
tracing::info!(peers = reply.peers.len(), primary = ?reply.primary_utility, "installer: RE-FED again — the same re-lay still settling; the relay and the routes follow it");
}
// A re-feed ADDS what changed: a value the install fed this front
// (its prices, its vendor, its console bearer) is never dropped by a
// re-admission that did not carry it.
let mut merged = reply.clone();
if let Some(kept) = self.latest() {
for (k, v) in kept.feed {
merged.feed.entry(k).or_insert(v);
}
}
self.fed(&merged)
}
/// **A renewed certificate for this front's own key** (nordisk-pairing 0.5.12,
/// `installer::renew`): kept where the install kept the first; the listener serves it from
/// the next connection. `renewal_refusal` already found the same key and name.
fn renewed(&self, cert_pem: &str) -> Result<(), String> {
gunnar_server::pairing::write_secret(&self.root, OWN_CERT_REL, cert_pem)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(
"installer: this front's own certificate was RENEWED (same key, a new lifetime)"
);
Ok(())
}
fn status(&self) -> Signal {
let name = self
.latest()
.map(|r| r.me.name)
.unwrap_or_else(|| "?".into());
let m = self.machine.lock().map(|m| m.clone()).unwrap_or_default();
Signal::of(&name, self.seed.tunnel, &m)
}
fn close(&self) -> Result<(), String> {
gunnar_server::durable::write_atomic_unframed(
&self.root,
CLOSED_REL,
b"closed by the installer\n",
)
.map_err(|e| format!("{e:#}"))?;
// The front's boot log keeps what was said up to here for life
// (nordisk-pairing 0.5.5, the pinned head).
gunnar_server::installer_listener::seal_install_head();
Ok(())
}
/// **A fresh bootstrap kept for the next join** (F4, nordisk-pairing 0.5.13): the seed on
/// disk takes the pair the installer handed with a renewal, so a front booted again from
/// its medium (an upgrade, a recharge) re-joins after the medium's own bootstrap has ended.
fn rebootstrapped(&self, boot: &mtls::Issued) -> Result<(), String> {
let rel = gunnar_server::pairing::SEED_REL;
let on_disk = std::fs::read_to_string(self.root.join(rel))
.ok()
.and_then(|t| Seed::parse(&t).ok())
.filter(|s| s.estate == self.seed.estate);
let mut seed = on_disk.unwrap_or_else(|| self.seed.clone());
seed.bootstrap_cert_pem = boot.cert_pem.clone();
seed.bootstrap_key_pem = boot.key_pem.clone();
seed.check()
.map_err(|e| format!("the seed with the fresh bootstrap: {e}"))?;
let rendered = seed
.render()
.map_err(|e| format!("the seed with the fresh bootstrap: {e}"))?;
gunnar_server::pairing::write_secret(&self.root, rel, &rendered)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(
"installer: the front keeps a FRESH bootstrap in its seed for its next join"
);
Ok(())
}
fn identity(&self) -> Issued {
match self.own_cert() {
Some(cert) => Issued {
cert_pem: cert,
key_pem: self.own_key.clone(),
},
None => Issued {
cert_pem: self.seed.bootstrap_cert_pem.clone(),
key_pem: self.seed.bootstrap_key_pem.clone(),
},
}
}
/// **What the front offers** — public values only it can mint, every visit:
///
/// | key | value | who takes it |
/// |---|---|---|
/// | `version` | the build this binary is (`gunnar_common::BUILD_REF`) | lane B's install check |
/// | `certify_pubkey` | the certify key's OpenSSH line, `… front-certify` | the primary: principal `front-certify` |
/// | `clock_key` | the time key's OpenSSH line | every box's clock |
/// | `clock_peer` | `peer://<utility>:50123` | every box's clock |
fn offer(&self) -> BTreeMap<String, String> {
let mut offer = BTreeMap::new();
offer.insert(
gunnar_pairing_door::OFFER_VERSION.to_string(),
gunnar_common::BUILD_REF.to_string(),
);
match crate::keys::certify_pubkey(&self.root) {
Ok(line) => {
offer.insert(
gunnar_pairing_door::front::OFFER_CERTIFY_PUBKEY.to_string(),
line,
);
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "installer: the front's certify key cannot be minted — not offered; the twin cannot be certified")
}
}
if let Some(c) = self.clock.lock().ok().and_then(|c| c.clone()) {
offer.insert(crate::clock::OFFER_CLOCK_KEY.to_string(), c.key_line);
if let Some(peer) = c.peer {
offer.insert(crate::clock::OFFER_CLOCK_PEER.to_string(), peer);
}
}
// The console's own key and its guest key (`console_pubkey`,
// `guest_pubkey`): the primary enrols `gunnar-ui` and `gunnar-guest`.
#[cfg(feature = "console")]
offer.extend(crate::console::offers(&self.root));
// The ONE monetize (M-D35): the Fleet door, the estate and the key shop-pairing
// fed this front go to every box — last, so the ONE's key wins over any of its own.
// And on the mock where they dial it and the certificate it serves (fleet_at, fleet_ca; A3).
if let Some(fed) = self.latest() {
for k in [
gunnar_server::feed::FEED_FLEET,
gunnar_server::feed::FEED_FLEET_INSTANCE,
gunnar_server::feed::FEED_MONETIZE,
gunnar_server::feed::FEED_FLEET_AT,
gunnar_server::feed::FEED_FLEET_CA,
] {
if let Some(v) = fed.feed.get(k).map(|v| v.trim()).filter(|v| !v.is_empty()) {
offer.insert(k.to_string(), v.to_string());
}
}
}
offer
}
/// **The hand-over** (edda nordisk-pairing 0.5.0): at the end of the
/// install, right before the close, the installer asks for what this
/// front's SUCCESSOR must be fed — every valid certificate kept on the
/// data disk and its private key, `tls.<host>.cert` + `tls.<host>.key`
/// ([`crate::web::certs::handover`]). Over the WireGuard link, mutual TLS;
/// the installer keeps it in the operator's 0600 store only and feeds it
/// to the next front (the Let's Encrypt limit). Logged by host, never a
/// byte of it.
fn secret_offer(&self) -> nordisk_pairing::bootstrap::SecretOffer {
let (values, hosts) = crate::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)");
}
nordisk_pairing::bootstrap::SecretOffer::new(values)
}
}
/// **The first boot after an installation** (`pid1`): the pairing state of the previous
/// installation is cleared ([`crate::PAIRING_STATE`]) so a front installed for a NEW estate
/// starts unpaired (MEASURED 2026-09-24: an old `joined.toml` resumed "as itself" and never
/// joined the new estate). `esp` is `/boot`, `data` the data root, `seed` the seed this boot
/// runs with. Returns what was cleared, or `None` when this is not a first boot.
///
/// **A KEEP boot of the same estate clears nothing** (owner 2026-10-17, the ONE box's
/// front-first update): the shop keep-boots the front from the release's front-keep medium —
/// a new ESP, so this IS a first boot, but /data is kept and the installer stages nothing
/// onto a kept volume (korp-installer: "staged declarations NOT written"), so the seed this
/// boot runs with is the kept one. When the kept, readable feed names that same estate, it
/// is still this front's: clearing it left the front unfed for ever ("the console waits for
/// the installer: no primary is known yet") — nobody feeds a front again, and it is the box
/// that pulls `SoldEstate.UpdateTake` and takes twin and main. Only the ESP is marked, so the
/// question is asked once. A feed that cannot be read, or names another estate, is cleared as
/// before.
pub fn first_boot(
esp: &Path,
data: &Path,
seed: &Seed,
write_marker: &dyn Fn(&Path) -> bool,
) -> Option<Vec<String>> {
let first = esp.join("korp").is_dir()
&& !esp
.join(gunnar_server::installer_listener::ESP_CLEARED)
.exists();
if first {
let kept = gunnar_server::fed_at_rest::read_checked(data, JOINED_REL)
.ok()
.flatten()
.and_then(|t| JoinReply::parse(&t).ok());
if kept
.as_ref()
.is_some_and(|r| r.estate == seed.estate && refuses(r).is_none())
{
if !write_marker(&esp.join(gunnar_server::installer_listener::ESP_CLEARED)) {
tracing::error!("pairing: a keep boot of this estate and the ESP marker could not be written — the pairing state is KEPT");
}
tracing::info!(estate = %seed.estate, "front: a keep boot of the same estate onto its kept data — the installer's feed, this box's key and certificate are KEPT");
return Some(Vec::new());
}
}
gunnar_server::installer_listener::reset_if_fresh_install_of(
esp,
data,
&crate::PAIRING_STATE,
write_marker,
)
}
#[cfg(test)]
mod tests {
use super::*;
use nordisk_pairing::join::{Assigned, Peer};
use std::net::Ipv4Addr;
use std::time::Duration;
fn a_seed() -> Seed {
let ca = mtls::InstallerCa::mint("gunnar.rs", Duration::from_secs(3600)).unwrap();
let boot = ca.issue_bootstrap(Duration::from_secs(3600)).unwrap();
Seed {
estate: "gunnar.rs".into(),
tunnel: TunnelKind::Wireguard,
front: nordisk_pairing::seed::Front::default(),
kind: Kind::Front,
installer_ca_pem: ca.cert_pem,
bootstrap_cert_pem: boot.cert_pem,
bootstrap_key_pem: boot.key_pem,
peers_expected: 3,
mullvad: None,
tailscale: None,
installer_link: None,
}
}
fn peer(name: &str, role: Role) -> Peer {
Peer {
name: name.into(),
role,
tunnel_pubkey: String::new(),
overlay: nordisk_pairing::OVERLAY_BOX_A,
endpoint: None,
utility: Some(Ipv4Addr::new(10, 13, 8, 2)),
}
}
fn fed(role: Option<Role>, peers: Vec<Peer>) -> JoinReply {
JoinReply {
estate: "gunnar.rs".into(),
me: Assigned {
name: "vm1".into(),
overlay: nordisk_pairing::OVERLAY_FRONT,
},
cert_pem: None,
role,
primary_overlay: None,
primary_utility: None,
peers,
feed: BTreeMap::new(),
}
}
/// **A rotated value is kept** in the reply the front serves (the console and
/// monetize read it) and on disk, sealed (h-admin-token, h-feed-at-rest).
#[test]
fn a_rotated_value_is_kept_in_the_served_feed_and_sealed_on_disk() {
let dir = tempfile::tempdir().unwrap();
let vm = FrontVm::open(dir.path(), a_seed()).unwrap();
let mut reply = fed(Some(Role::Front), vec![]);
reply
.feed
.insert("console_admin_token".into(), "a".repeat(64));
vm.fed(&reply).unwrap();
assert_eq!(vm.rotate("console_admin_token", &"b".repeat(64)), Ok(true));
assert_eq!(
vm.rotate("console_admin_token", &"b".repeat(64)),
Ok(false),
"the same value again is UNCHANGED"
);
assert_eq!(
vm.latest().unwrap().feed["console_admin_token"],
"b".repeat(64)
);
let raw = std::fs::read(dir.path().join(JOINED_REL)).unwrap();
assert!(
!String::from_utf8_lossy(&raw).contains(&"b".repeat(64)),
"sealed on disk"
);
// A restart reads the rotated value back.
let again = FrontVm::open(dir.path(), a_seed()).unwrap();
assert_eq!(
again.latest().unwrap().feed["console_admin_token"],
"b".repeat(64)
);
// Nothing fed yet: nothing to rotate.
let empty = tempfile::tempdir().unwrap();
assert!(FrontVm::open(empty.path(), a_seed())
.unwrap()
.rotate("console_admin_token", "x")
.is_err());
}
/// vm1 presents kind `front` with the relay's key, and no capacity: the
/// election is between the two gunnar boxes.
#[test]
fn the_front_presents_kind_front_with_its_relay_key_and_no_capacity() {
let dir = tempfile::tempdir().unwrap();
let vm = FrontVm::open(dir.path(), a_seed()).unwrap();
let h = vm.hello().unwrap();
assert_eq!(h.kind, Kind::Front);
assert_eq!(
h.tunnel_pubkey,
gunnar_server::pairing::tunnel_pubkey(dir.path()),
"the key the relay runs on"
);
assert!(!h.tunnel_pubkey.is_empty() && h.capacity.is_none() && !h.csr_pem.is_empty());
}
/// The front medium is vm1 and nothing else: a feed with any other role
/// is refused by name and not kept.
#[test]
fn the_front_medium_refuses_any_role_but_front_and_keeps_nothing() {
let dir = tempfile::tempdir().unwrap();
let vm = FrontVm::open(dir.path(), a_seed()).unwrap();
for role in [None, Some(Role::Primary), Some(Role::Twin)] {
let e = vm.fed(&fed(role, vec![])).unwrap_err();
assert!(e.contains("[front-medium-is-only-the-front]"), "{e}");
}
assert!(vm.latest().is_none() && !dir.path().join(JOINED_REL).exists());
}
/// **A closed front takes the installer's RE-FEED** (nordisk-pairing
/// 0.5.8, `installer::readmit`): the main was laid again at a new address
/// with a new tunnel key; the front keeps the new answer (the role thread
/// re-keys the relay and re-certifies from it) and refuses a re-feed that
/// would change what it is.
#[test]
fn a_closed_front_takes_a_refeed_naming_the_recharged_main() {
let dir = tempfile::tempdir().unwrap();
let vm = FrontVm::open(dir.path(), a_seed()).unwrap();
let mut first = fed(
Some(Role::Front),
vec![
peer("vm1", Role::Front),
peer("vm2", Role::Primary),
peer("vm3", Role::Twin),
],
);
first
.feed
.insert("plugin.gunnar.prices".into(), "[prices]".into());
vm.fed(&first).unwrap();
let moved = Peer {
utility: Some(Ipv4Addr::new(10, 13, 9, 140)),
tunnel_pubkey: "NEWKEY".into(),
..peer("vm2", Role::Primary)
};
let refed = JoinReply {
primary_utility: Some(Ipv4Addr::new(10, 13, 9, 140)),
..fed(
Some(Role::Front),
vec![peer("vm1", Role::Front), moved, peer("vm3", Role::Twin)],
)
};
vm.refed(&refed).expect("the re-feed is taken");
assert_eq!(
vm.latest().and_then(|r| r.primary_utility),
Some(Ipv4Addr::new(10, 13, 9, 140))
);
// joined.toml is kept checked at rest (ops-hardening fed_at_rest): read it that way.
let kept = JoinReply::parse(
&gunnar_server::fed_at_rest::read_checked(dir.path(), JOINED_REL)
.unwrap()
.expect("kept"),
)
.unwrap();
assert_eq!(
kept.primary_utility,
Some(Ipv4Addr::new(10, 13, 9, 140)),
"kept for the next start"
);
assert_eq!(
kept.feed.get("plugin.gunnar.prices").map(String::as_str),
Some("[prices]"),
"a re-feed ADDS what changed; the front's own values are never dropped by it"
);
let renamed = JoinReply {
me: Assigned {
name: "front2".into(),
overlay: nordisk_pairing::OVERLAY_FRONT,
},
..refed
};
assert!(vm
.refed(&renamed)
.unwrap_err()
.contains("[refeed-changes-the-vm]"));
}
/// PAIRED once the relay is up and the reply lists front + main + twin —
/// and it survives a restart from `joined.toml`.
#[test]
fn the_front_is_paired_when_its_relay_is_up_and_every_box_is_listed() {
let dir = tempfile::tempdir().unwrap();
let vm = FrontVm::open(dir.path(), a_seed()).unwrap();
vm.fed(&fed(Some(Role::Front), vec![peer("vm1", Role::Front)]))
.unwrap();
assert!(!vm.status().paired && vm.status().joined);
vm.relay_started();
assert!(!vm.status().paired, "main and twin are not listed yet");
vm.fed(&fed(
Some(Role::Front),
vec![
peer("vm1", Role::Front),
peer("vm2", Role::Primary),
peer("vm3", Role::Twin),
],
))
.unwrap();
assert!(vm.status().paired, "{:?}", vm.status());
assert!(dir.path().join(SIGNAL_REL).exists());
// A restart resumes as itself and is paired again once its relay is.
let again = FrontVm::open(dir.path(), a_seed()).unwrap();
assert_eq!(again.latest().map(|r| r.me.name).as_deref(), Some("vm1"));
again.relay_started();
assert!(again.status().paired);
}
/// **The certificate hand-over, over the REAL bootstrap listener** (edda
/// nordisk-pairing 0.5.0): the front hands the installer every VALID
/// certificate it keeps, with its key, as `tls.<host>.cert` + `.key` on
/// the SECRET offer only (the public offer never carries it); the
/// successor front, fed exactly that, serves it at once and asks Let's
/// Encrypt nothing.
#[test]
fn the_front_hands_its_kept_certificate_and_key_to_the_installer_for_its_successor() {
use crate::web::certs::{self, Cert};
use std::time::SystemTime;
let h = Duration::from_secs(3600);
let ca = mtls::InstallerCa::mint("gunnar.rs", h).unwrap();
let boot = ca.issue_bootstrap(h).unwrap();
let seed = Seed {
installer_ca_pem: ca.cert_pem.clone(),
bootstrap_cert_pem: boot.cert_pem,
bootstrap_key_pem: boot.key_pem,
..a_seed()
};
let dir = tempfile::tempdir().unwrap();
let vm = std::sync::Arc::new(FrontVm::open(dir.path(), seed.clone()).unwrap());
assert!(
vm.secret_offer().is_empty(),
"nothing kept, nothing handed over"
);
// The certificate this front serves, kept on its data disk ...
let (c, k) = certs::self_signed(&["gunnar.rs", "www.gunnar.rs"], 80);
certs::keep(
dir.path(),
"gunnar.rs",
&Cert::parse("gunnar.rs", &c, &k, SystemTime::now()).unwrap(),
)
.unwrap();
// ... and one that has run out, which is not handed over.
let (old, old_key) = certs::self_signed(&["old.gunnar.rs"], 0);
certs::keep(
dir.path(),
"old.gunnar.rs",
&Cert::parse(
"old.gunnar.rs",
&old,
&old_key,
SystemTime::now() - Duration::from_secs(7200),
)
.unwrap(),
)
.unwrap();
std::thread::sleep(Duration::from_millis(1100));
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let std::net::SocketAddr::V4(at) = l.local_addr().unwrap() else {
panic!()
};
let log = nordisk_pairing::bootlog::BootLog::new(16);
let (v, ca_pem) = (vm.clone(), seed.installer_ca_pem.clone());
let served = std::thread::spawn(move || {
nordisk_pairing::bootstrap::serve(v.as_ref(), &log, &ca_pem, l, &|_| {})
});
let me = ca.issue_installer(h).unwrap();
// A hang guard, not a deadline (R5.1, 2026-10-07): the test waits for the front's answer.
// At load 19 on dwarf this test took 36 s and the old 5 s read timeout failed a front
// that was only slow; a front that never answers still fails, after two minutes.
const HANG_GUARD: Duration = Duration::from_secs(120);
let mut client = nordisk_pairing::installer::Client::new(
Box::new(nordisk_pairing::installer_link::DirectPath { at }),
&ca.cert_pem,
&me,
HANG_GUARD,
)
.unwrap();
let handed = client.secret_offer().unwrap();
assert_eq!(
handed.keys(),
vec![
"tls.gunnar.rs.cert".to_string(),
"tls.gunnar.rs.key".to_string()
],
"the valid one, whole; not the expired one"
);
assert_eq!(handed.expose()["tls.gunnar.rs.key"], k);
assert_eq!(handed.expose()["tls.gunnar.rs.cert"], c);
assert!(
!format!("{handed:?}").contains("PRIVATE"),
"its Debug shows keys only"
);
let public = client.offer().unwrap();
assert!(
public.keys().all(|k| !k.starts_with("tls."))
&& public.values().all(|v| !v.contains("PRIVATE")),
"the public offer never carries it: {:?}",
public.keys()
);
client.close().unwrap();
served.join().unwrap();
// The successor: fed what was handed over, it serves it and does not ask ACME.
let next = tempfile::tempdir().unwrap();
let feed = handed.into_inner();
let web = crate::web::Web::new(next.path());
let cfg = crate::web::WebConfig::from("gunnar.rs", &feed);
let (acme_due, lines) = web.look(&cfg, &feed, SystemTime::now());
assert!(
!acme_due,
"no Let's Encrypt certificate is spent: {lines:?}"
);
assert!(web.resolver.any(), "served at once");
assert_eq!(
certs::kept(next.path(), "gunnar.rs", SystemTime::now())
.unwrap()
.unwrap()
.chain_pem,
c,
"and kept on the successor's disk, so it hands it over in turn"
);
}
/// Every visit: the build, the certify key; the clock's key and peer once
/// the clock has started. Public values only.
#[test]
fn the_front_offers_its_build_its_certify_key_and_its_clock() {
let dir = tempfile::tempdir().unwrap();
let vm = FrontVm::open(dir.path(), a_seed()).unwrap();
let o = vm.offer();
assert_eq!(
o[gunnar_pairing_door::OFFER_VERSION],
gunnar_common::BUILD_REF
);
assert!(o[gunnar_pairing_door::front::OFFER_CERTIFY_PUBKEY].ends_with(" front-certify"));
assert!(
!o.contains_key(crate::clock::OFFER_CLOCK_KEY),
"no clock yet"
);
#[cfg(feature = "console")]
assert!(
o[crate::console::OFFER_CONSOLE_PUBKEY].ends_with(" gunnar-ui")
&& o[crate::console::OFFER_GUEST_PUBKEY].ends_with(" gunnar-guest"),
"the console's keys are offered: {o:?}"
);
vm.clock_started(crate::clock::ClockOffer {
key_line: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK0 x".into(),
peer: Some(crate::clock::peer_spelling(Ipv4Addr::new(10, 13, 8, 1))),
});
let o = vm.offer();
assert_eq!(o[crate::clock::OFFER_CLOCK_PEER], "peer://10.13.8.1:50123");
assert!(o[crate::clock::OFFER_CLOCK_KEY].starts_with("ssh-ed25519 "));
assert!(
o.values().all(|v| !v.contains("PRIVATE")),
"never a private key: {o:?}"
);
// What a gunnar box takes from these offers (gunnar-server feed.rs).
let feed: BTreeMap<String, String> = o
.iter()
.map(|(k, v)| (format!("offer.vm1.{k}"), v.clone()))
.collect();
let f = gunnar_server::feed::Fed::from_feed(&feed);
assert_eq!(f.clock_peer.as_deref(), Some("peer://10.13.8.1:50123"));
assert!(f.clock_key.is_some());
}
/// ONE monetize (M-D35; BROKEN "gunnar-front must take fleet, fleet_instance and
/// monetize_pubkey from its feed and hand them to main and twin"): what shop-pairing
/// fed the front is offered to every box, the ONE's key over any key of the front's own.
#[test]
fn the_front_hands_the_ones_fleet_door_its_estate_and_its_key_to_every_box() {
let dir = tempfile::tempdir().unwrap();
let vm = FrontVm::open(dir.path(), a_seed()).unwrap();
let one_key = "43cb00000000000000000000000000000000000000000000000000000000842a";
let feed = BTreeMap::from([
(
gunnar_server::feed::FEED_FLEET.to_string(),
"https://monetize.ignalina.dk:8443".to_string(),
),
(
gunnar_server::feed::FEED_FLEET_INSTANCE.to_string(),
"acme".to_string(),
),
(
gunnar_server::feed::FEED_MONETIZE.to_string(),
one_key.to_string(),
),
]);
*vm.fed.lock().unwrap() = Some(JoinReply {
estate: "gunnar.rs".into(),
me: Assigned {
name: "front".into(),
overlay: nordisk_pairing::OVERLAY_FRONT,
},
cert_pem: None,
role: Some(Role::Front),
primary_overlay: None,
primary_utility: None,
peers: vec![],
feed,
});
let o = vm.offer();
let boxes: BTreeMap<String, String> = o
.iter()
.map(|(k, v)| (format!("offer.front.{k}"), v.clone()))
.collect();
let f = gunnar_server::feed::Fed::from_feed(&boxes);
assert_eq!(
f.fleet.as_deref(),
Some("https://monetize.ignalina.dk:8443"),
"{o:?}"
);
assert_eq!(f.fleet_instance.as_deref(), Some("acme"));
assert_eq!(
f.monetize_pubkey.as_deref(),
Some(one_key),
"the ONE's key, never the front's own"
);
// ★ A3 (the mock's private buy, 2026-10-11): where the boxes DIAL the ONE box and the
// certificate it serves there (`fleet_at`, `fleet_ca` — the mock's seam, fed by the
// shop's sale feed) reach every box too, or a sold main reports to nobody on the mock.
let mut fed = vm.fed.lock().unwrap().clone().unwrap();
fed.feed.insert(
gunnar_server::feed::FEED_FLEET_AT.to_string(),
"10.6.0.9:443".to_string(),
);
fed.feed.insert(
gunnar_server::feed::FEED_FLEET_CA.to_string(),
"-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----\n".to_string(),
);
*vm.fed.lock().unwrap() = Some(fed);
let boxes: BTreeMap<String, String> = vm
.offer()
.iter()
.map(|(k, v)| (format!("offer.front.{k}"), v.clone()))
.collect();
let f = gunnar_server::feed::Fed::from_feed(&boxes);
assert_eq!(f.fleet_at.as_deref(), Some("10.6.0.9:443"));
assert!(f
.fleet_ca
.as_deref()
.is_some_and(|c| c.contains("BEGIN CERTIFICATE")));
let unfed = tempfile::tempdir().unwrap();
let o = FrontVm::open(unfed.path(), a_seed()).unwrap().offer();
assert!(
!o.contains_key(gunnar_server::feed::FEED_FLEET)
&& !o.contains_key(gunnar_server::feed::FEED_FLEET_INSTANCE),
"nothing fed, nothing offered: {o:?}"
);
}
/// ★ **A front KEEP-BOOTED onto its own /data comes up FED** (owner 2026-10-17, the ONE
/// box's front-first update): the shop keep-boots the front from the release's front-keep
/// medium — a new ESP (so a "first boot after an installation"), /data kept, its seed and
/// its sealed `joined.toml` untouched. The front must still be fed after it: it is the one
/// that pulls `SoldEstate.UpdateTake` and takes twin and main, and nobody feeds it again.
/// Seen RED on the mock lane: "the console waits for the installer: no primary is known
/// yet" for ever, twin and main `waiting`.
#[test]
fn a_front_keep_booted_onto_its_kept_data_comes_up_fed_and_can_reach_its_shop() {
let data = tempfile::tempdir().unwrap();
let seed = a_seed();
let vm = FrontVm::open(data.path(), seed.clone()).unwrap();
let mut reply = fed(
Some(Role::Front),
vec![peer("vm2", Role::Primary), peer("vm3", Role::Twin)],
);
reply.feed.insert(
"shop_door.toml".into(),
"door = \"https://one.example:443\"\n".into(),
);
reply
.feed
.insert("secret.shop_client_key".into(), "k".into());
vm.fed(&reply).unwrap();
let own_key = std::fs::read_to_string(data.path().join(OWN_KEY_REL)).unwrap();
drop(vm);
// The keep boot: a new ESP (korp/ present, no cleared marker), the same /data.
let esp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(esp.path().join("korp")).unwrap();
let cleared = first_boot(esp.path(), data.path(), &seed, &|p| {
std::fs::write(p, b"x").is_ok()
});
assert!(
cleared.as_ref().is_none_or(|c| c.is_empty()),
"a keep boot of the same estate clears nothing: {cleared:?}"
);
assert!(
esp.path()
.join(gunnar_server::installer_listener::ESP_CLEARED)
.exists(),
"the boot is marked, so it is decided once"
);
let after = FrontVm::open(data.path(), seed.clone()).unwrap();
let back = after.latest().expect("the keep-booted front is still fed");
assert_eq!(back.peers.len(), 2);
assert_eq!(
std::fs::read_to_string(data.path().join(OWN_KEY_REL)).unwrap(),
own_key,
"its own key kept"
);
assert!(
back.feed.contains_key("shop_door.toml"),
"the fed shop door is there to pull UpdateTake from"
);
#[cfg(feature = "metered")]
assert!(
crate::recharge::shop_door(&back.feed).is_some(),
"the update loop finds its shop door in the kept feed"
);
}
/// The other half of the rule, unchanged: a front installed for a DIFFERENT estate onto a
/// kept volume starts unpaired (MEASURED 2026-09-24: an old `joined.toml` resumed "as
/// itself" and never joined the new estate).
#[test]
fn a_front_installed_for_another_estate_onto_kept_data_still_starts_unpaired() {
let data = tempfile::tempdir().unwrap();
let vm = FrontVm::open(data.path(), a_seed()).unwrap();
vm.fed(&fed(Some(Role::Front), vec![])).unwrap();
drop(vm);
let mut other = a_seed();
other.estate = "test2.gunnar.rs".into();
let esp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(esp.path().join("korp")).unwrap();
let cleared = first_boot(esp.path(), data.path(), &other, &|p| {
std::fs::write(p, b"x").is_ok()
})
.expect("a first boot");
assert!(cleared.iter().any(|c| c == JOINED_REL), "{cleared:?}");
assert!(!data.path().join(JOINED_REL).exists());
}
}
/// How long after one loud RE-FED the next ones of the same re-lay are said at INFO. A recharge
/// re-feeds the front once per step of the moved box (mock gate chain 19, 2026-10-25: 7 in 60 s,
/// which the log gate refuses as WARN spam); the operator needs to hear the re-lay once.
pub const REFEED_LOUD_EVERY: std::time::Duration = std::time::Duration::from_secs(300);
static LAST_LOUD_REFEED: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
/// Whether a RE-FED at `now` is said at WARN: the first, and then one per [`REFEED_LOUD_EVERY`].
fn refeed_said_loud(now: std::time::Instant) -> bool {
let Ok(mut last) = LAST_LOUD_REFEED.lock() else {
return true;
};
loud_after(&mut last, now)
}
fn loud_after(last: &mut Option<std::time::Instant>, now: std::time::Instant) -> bool {
match *last {
Some(t) if now.saturating_duration_since(t) < REFEED_LOUD_EVERY => false,
_ => {
*last = Some(now);
true
}
}
}
#[cfg(test)]
mod refeed_loudness_tests {
use super::*;
#[test]
fn a_re_lay_is_said_loud_once_then_quietly_until_the_window_passes() {
let t0 = std::time::Instant::now();
let mut last = None;
assert!(loud_after(&mut last, t0), "the first re-feed is a WARN");
for s in [2u64, 5, 7, 14, 21, 51] {
assert!(
!loud_after(&mut last, t0 + std::time::Duration::from_secs(s)),
"+{s} s: INFO"
);
}
assert!(
loud_after(&mut last, t0 + REFEED_LOUD_EVERY),
"a re-lay after the window is loud again"
);
}
}