crates/gunnar-pairing-door/src/certify.rs
//! **The twin's certification — the door finishes the pairing.**
//!
//! Until 2026-09-24 the laptop's xtask did this over ssh on the front:
//! `gunnar control twin join` against the twin, the key it printed enrolled on
//! the primary (`private-gunnar-ops` `xtask/src/reimage/twin.rs`
//! `enrol_commands`), then `gunnar control twin certify`
//! (`crates/gunnar-cli/src/control/twin.rs` `certify`). The front holds the
//! console key the primary was fed as its first admin, so once the door's
//! election exists the door does it itself, in a background thread, retrying
//! until it took.
//!
//! **What the twin's follower needs, and only that** (read from the code, not
//! the ladder):
//!
//! 1. **the twin's key** — `Bootstrap.BecomeTwin` on the twin with the SAME
//! primary address its own pairing loop gave (`<primary utility>:50051`,
//! `gunnar-server/src/pairing.rs`): idempotent for the same primary, it
//! answers the key line `twin.key` holds. Asked first by the twin or by the
//! door, the outcome is the same box.
//! 2. **the key enrolled on the primary** — machine principal
//! [`TWIN_PRINCIPAL`], that key, group `twin`, and the twin a member. The
//! follower signs `Mirror.LogHead`/`LogFetch`/`SnapshotFetch` with
//! `twin.key`, and `MirrorService::require_log_reader` lets a member of
//! group `twin` read the log WITHOUT a server grant; and
//! `twin_tls::issue_for_twin` refuses a principal that is not a member.
//! So no `grant … admin --server` (the group already opens the log), and no
//! `replication add` (that is `Mirror.SetReplication`, the roster the growth
//! drain counts — not something the follower reads).
//! 3. **the certificate** — `Bootstrap.TwinCsr` on the twin (a request for the
//! key that never leaves it, CN `gunnar-twin`), `Admin.IssueTwinCertificate`
//! on the primary signed with the console key, `Bootstrap.InstallTwinCertificate`
//! on the twin. The follower picks the pairing up on its next attempt.
use nordisk_pairing::door::Door;
use nordisk_pairing::CONTROL_PORT;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
/// The principal, the calls and the answers are gunnar-repair's `adopt_twin` —
/// the ONE implementation (5.4); this door is a caller.
pub use gunnar_repair::adopt::{Boxes, Issued, Paired, TWIN_PRINCIPAL};
/// **Which boxes, at which addresses** — from the door's verdict and slots.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
pub twin: String,
pub primary: String,
/// The twin's control plane, over the tunnel (`<overlay>:50051`).
pub twin_ctl: String,
/// The primary's control plane, over the tunnel.
pub primary_ctl: String,
/// The primary as the TWIN was told it (`<overlay>:50051`) — what
/// `BecomeTwin` must be given again for its idempotent answer.
pub primary_for_twin: String,
/// **Which boxes these are** — their utility addresses (or tunnel keys).
/// A box laid again keeps its overlay address, so the dialled strings
/// alone cannot tell a reborn twin from the one certified before; this can.
pub generation: String,
}
impl Target {
/// `None` until the election has a verdict.
pub fn of(door: &Door) -> Option<Target> {
let v = door.verdict.as_ref()?;
// Over the tunnel (WireGuard-only, owner 2026-10-04): every box's
// OVERLAY address, from its slot; a `--wg-only` box refuses its
// utility address.
let (twin_key, twin_slot) = door.ledger.slot_of(&v.twin)?;
let (primary_key, primary_slot) = door.ledger.slot_of(&v.primary)?;
let (twin_at, primary_at) = (twin_slot.overlay, primary_slot.overlay);
let generation = format!(
"{} {}",
twin_slot.utility_or(*twin_key),
primary_slot.utility_or(*primary_key)
);
Some(Target {
twin: v.twin.clone(),
primary: v.primary.clone(),
twin_ctl: format!("{twin_at}:{CONTROL_PORT}"),
primary_ctl: format!("{primary_at}:{CONTROL_PORT}"),
// The primary's OVERLAY address, as the twin's pairing loop gives it
// under --wg-only (`tunnel_addr::primary_control`). BecomeTwin is
// idempotent only for the SAME string, and a twin is never re-pointed.
primary_for_twin: format!("{primary_at}:{CONTROL_PORT}"),
generation,
})
}
}
impl Target {
/// The pair, as one string: `<twin_ctl> <primary_ctl>`.
pub fn key(&self) -> String {
if self.generation.is_empty() {
format!("{} {}", self.twin_ctl, self.primary_ctl)
} else {
format!("{} {} {}", self.twin_ctl, self.primary_ctl, self.generation)
}
}
}
/// **One attempt, every step** — [`gunnar_repair::adopt::adopt_twin`] for
/// this target, as [`TWIN_PRINCIPAL`]. `Err` names the step it stopped at.
pub fn once(boxes: &dyn Boxes, t: &Target) -> Result<Paired, String> {
gunnar_repair::adopt::adopt_twin(
boxes,
&gunnar_repair::adopt::Adopt {
twin_ctl: t.twin_ctl.clone(),
primary_ctl: t.primary_ctl.clone(),
primary_for_twin: t.primary_for_twin.clone(),
principal: TWIN_PRINCIPAL.into(),
},
)
}
/// **The outcome, kept beside the ledger** (`door.certify.toml`): a restarted
/// door neither redoes a pairing that took nor forgets why one has not.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
pub certified: bool,
#[serde(default)]
pub attempts: u64,
#[serde(default)]
pub twin: String,
#[serde(default)]
pub primary: String,
/// The last attempt's refusal, by step; empty once certified.
#[serde(default)]
pub why: String,
#[serde(default)]
pub replication: String,
#[serde(default)]
pub days_left: i64,
#[serde(default)]
pub at_unix_ms: u64,
/// The pair it was certified for ([`Target::key`]); empty in a record
/// written before 2026-09-29.
#[serde(default)]
pub for_target: String,
}
/// `door.toml` → `door.certify.toml`.
pub fn record_path(ledger: &Path) -> PathBuf {
ledger.with_extension("certify.toml")
}
impl Record {
/// Absent is a fresh record; unreadable is said, not guessed over.
pub fn load(path: &Path) -> Result<Record, String> {
match std::fs::read_to_string(path) {
Ok(text) => toml::from_str(&text)
.map_err(|e| format!("{}: not a certify record: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Record::default()),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
/// Whole, then renamed — the ledger's rule.
pub fn save(&self, path: &Path) -> Result<(), String> {
let text = toml::to_string(self).map_err(|e| e.to_string())?;
let tmp = path.with_extension("part");
std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, path)
.map_err(|e| format!("{} → {}: {e}", tmp.display(), path.display()))
}
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// The door's waits between attempts: 5 s doubling to 30 s, for ever. The
/// primary answers Unauthenticated until it was fed this front's key and
/// `Unavailable [key-enrolling]` until its pairing loop enrolled it, and the
/// twin has no key before it became the twin — all are minutes, not failures.
pub fn door_waits() -> impl Iterator<Item = Duration> {
std::iter::successors(Some(Duration::from_secs(5)), |d| {
Some((*d * 2).min(Duration::from_secs(30)))
})
}
/// **How a refused attempt is said** (stinkies u3-certify-before-twin-refed).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
/// Expected: said at INFO, asked again soon.
Expected,
/// A refusal worth an operator's eye: said as before.
Refused,
}
/// How long after the loop starts a "not fed this front's key yet" refusal
/// is the expected one.
pub const NOT_FED_YET_FOR: Duration = Duration::from_secs(90);
/// The wait after an expected refusal, at most.
pub const EXPECTED_RETRY: Duration = Duration::from_secs(2);
/// [`Tone`] of a refusal `e`, `since_start` into the loop.
///
/// EXPECTED: a box that does not know this front's certify key YET — the twin
/// answering `[bootstrap-unauthenticated]` to `BecomeTwin`, the primary's
/// "did not verify against any registered key" to the enrol — inside
/// [`NOT_FED_YET_FOR`] of the loop's start. The installer feeds the front
/// FIRST and re-feeds the twin and the primary the front's offer (its certify
/// key among it) a moment later, and the front starts certifying the instant
/// it is fed the election — so the first attempt can lose that race on any
/// lay of a new front (U3, R3). Every other refusal, and this one past the
/// window, is said as before.
///
/// EXPECTED at ANY time: the primary's own word that this front's key was fed
/// to it and is being enrolled (`Unavailable` [`gunnar_grpc::KEY_ENROLLING`],
/// cov-E r3 2026-10-06) — not a race this loop lost but a step the primary is
/// in; it is asked again within [`EXPECTED_RETRY`], so the enrolment is used
/// the moment it lands.
pub fn tone(e: &str, since_start: Duration) -> Tone {
if e.contains(gunnar_grpc::KEY_ENROLLING) {
return Tone::Expected;
}
let not_fed_yet = e.contains("[bootstrap-unauthenticated]")
|| e.contains("did not verify against any registered key");
if not_fed_yet && since_start < NOT_FED_YET_FOR {
Tone::Expected
} else {
Tone::Refused
}
}
/// The wait before the next attempt after a refusal of `tone`: an expected
/// one is asked again within [`EXPECTED_RETRY`] (the re-feed is ms away).
pub fn next_wait(tone: Tone, planned: Duration) -> Duration {
match tone {
Tone::Expected => planned.min(EXPECTED_RETRY),
Tone::Refused => planned,
}
}
/// **The front's first certify waits this long after the election**, so the
/// installer's re-feed of the twin and the primary (this front's certify key)
/// usually lands first; the race it does not win is [`Tone::Expected`].
pub const SETTLE: Duration = Duration::from_secs(3);
/// **Attempt until it takes** (or `waits` runs out — the tests' bound). Every
/// attempt is recorded and saved, so `/pair/signal` says where it is.
pub fn run(
boxes: &dyn Boxes,
t: &Target,
record: &Mutex<Record>,
path: Option<&Path>,
waits: impl IntoIterator<Item = Duration>,
) -> bool {
run_inner(boxes, t, record, path, waits, None)
}
fn run_inner(
boxes: &dyn Boxes,
t: &Target,
record: &Mutex<Record>,
path: Option<&Path>,
waits: impl IntoIterator<Item = Duration>,
wanted: Option<&Mutex<Option<String>>>,
) -> bool {
let pair = t.key();
let still = || {
wanted.is_none_or(|w| {
w.lock().unwrap_or_else(|p| p.into_inner()).as_deref() == Some(pair.as_str())
})
};
let superseded = || {
eprintln!("gunnar-pairing-door: twin certification for `{}` at {} stops: the front was re-fed another pair (the twin or the primary was laid again elsewhere)", t.twin, t.twin_ctl);
false
};
let mut waits = waits.into_iter();
let started = std::time::Instant::now();
loop {
if !still() {
return superseded();
}
let result = once(boxes, t);
let snapshot = {
let mut r = record.lock().unwrap_or_else(|p| p.into_inner());
// Judged under the record's lock: a loop the front re-fed away from
// while it dialled writes nothing over the new pair's record.
if !still() {
drop(r);
return superseded();
}
r.attempts += 1;
if r.for_target != t.key() {
// Another pair: what was certified before is not this one.
r.certified = false;
r.for_target = t.key();
}
r.twin = t.twin.clone();
r.primary = t.primary.clone();
r.at_unix_ms = now_ms();
match &result {
Ok(p) => {
r.certified = true;
r.why.clear();
r.replication = p.replication.clone();
r.days_left = p.days_left;
}
Err(e) => r.why = e.clone(),
}
r.clone()
};
match &result {
Ok(p) => eprintln!("gunnar-pairing-door: twin `{}` certified for mutual TLS 1.3 against `{}` — replication {}, {} day(s)", t.twin, t.primary, p.replication, p.days_left),
Err(e) => match tone(e, started.elapsed()) {
// Said through tracing at INFO: the front's stderr is captured
// at WARN, and this one is the race the module doc names.
Tone::Expected => tracing::info!(
attempt = snapshot.attempts,
"gunnar-pairing-door: twin certification, attempt {}: not yet — a box has not been fed this front's certify key yet (the installer re-feeds it a moment after the front); asking again within {} s: {e}",
snapshot.attempts,
EXPECTED_RETRY.as_secs()
),
Tone::Refused => eprintln!("gunnar-pairing-door: twin certification, attempt {}: {e}", snapshot.attempts),
},
}
if let Some(path) = path {
if let Err(e) = snapshot.save(path) {
eprintln!("gunnar-pairing-door: the certify record could not be saved: {e}");
}
}
if result.is_ok() {
return true;
}
if !still() {
return superseded();
}
let said = match &result {
Err(e) => tone(e, started.elapsed()),
Ok(_) => Tone::Refused,
};
match waits.next() {
Some(w) => std::thread::sleep(next_wait(said, w)),
None => return false,
}
}
}
/// **The pair the front wants certified NOW** ([`Target::key`]), shared by the
/// front with every certify loop it started: a re-feed that moves the twin or
/// the primary hands the new pair to a new loop, and the loop for the old pair
/// stops (estate-box3 life1 R2: it dialled the DEAD twin for 10+ min).
pub type Wanted = std::sync::Arc<Mutex<Option<String>>>;
/// [`run`], for as long as `wanted` names this loop's pair: a loop whose pair
/// the front no longer wants says so once and stops — before an attempt, after
/// one (its outcome is then not written: the record is the new pair's), and
/// before it sleeps.
pub fn run_while(
boxes: &dyn Boxes,
t: &Target,
record: &Mutex<Record>,
path: Option<&Path>,
waits: impl IntoIterator<Item = Duration>,
wanted: &Mutex<Option<String>>,
) -> bool {
run_inner(boxes, t, record, path, waits, Some(wanted))
}
fn quoted(s: &str) -> String {
toml::Value::String(s.to_string()).to_string()
}
/// **The certify lines of `/pair/signal`.** `configured` = the door holds a
/// console key; `elected` = the verdict exists.
pub fn signal(record: &Record, configured: bool, elected: bool) -> String {
if record.certified {
return format!(
"twin_certified = true\ntwin_replication = {}\ntwin_cert_days_left = {}\n",
quoted(&record.replication),
record.days_left
);
}
let why = if !configured {
"off: the door was started without --console-key, so nothing here can sign for the primary"
.to_string()
} else if !elected {
"waiting for the election (both gunnar boxes must join)".to_string()
} else if record.why.is_empty() {
"the first attempt is under way".to_string()
} else {
record.why.clone()
};
format!(
"twin_certified = false\ntwin_certify_attempts = {}\ntwin_certify_why = {}\n",
record.attempts,
quoted(&why)
)
}
#[cfg(test)]
mod tests {
use super::*;
use nordisk_pairing::elect::Verdict;
use nordisk_pairing::join::{Ledger, Slot};
use nordisk_pairing::seed::Kind;
use std::cell::RefCell;
use std::net::Ipv4Addr;
const MAIN: Ipv4Addr = Ipv4Addr::new(10, 13, 9, 90);
const TWIN: Ipv4Addr = Ipv4Addr::new(10, 13, 9, 87);
fn door(elected: bool) -> Door {
let mut l = Ledger::new("gunnar.rs");
l.lay(
MAIN,
Slot {
name: "main".into(),
kind: Kind::Gunnar,
overlay: nordisk_pairing::OVERLAY_BOX_A,
endpoint: None,
utility: None,
},
);
l.lay(
TWIN,
Slot {
name: "twin".into(),
kind: Kind::Gunnar,
overlay: nordisk_pairing::OVERLAY_BOX_B,
endpoint: None,
utility: None,
},
);
let mut d = Door::new(l);
if elected {
// The election's weakest can be EITHER slot: the verdict, not the
// slot's name, decides who is certified.
d.verdict = Some(Verdict {
twin: "main".into(),
primary: "twin".into(),
why: "main is weaker".into(),
});
}
d
}
/// Two fake control planes: what the door asked of each, and a primary that
/// refuses until its fed admin key is in (`admin_after` attempts).
#[derive(Default)]
struct Fake {
calls: RefCell<Vec<String>>,
enrolled: RefCell<Vec<(String, String)>>,
admin_after: u32,
enrol_tries: RefCell<u32>,
installed: RefCell<Option<Issued>>,
}
impl Boxes for Fake {
fn twin_key(&self, twin_ctl: &str, primary: &str) -> Result<String, String> {
self.calls
.borrow_mut()
.push(format!("become {twin_ctl} of {primary}"));
Ok("ssh-ed25519 AAAAtwin gunnar-twin\n".into())
}
fn enrol(&self, primary_ctl: &str, principal: &str, key: &str) -> Result<(), String> {
self.calls
.borrow_mut()
.push(format!("enrol {principal} at {primary_ctl}"));
let n = *self.enrol_tries.borrow() + 1;
*self.enrol_tries.borrow_mut() = n;
if n <= self.admin_after {
return Err(
"Unauthenticated: the credential did not verify against any registered key"
.into(),
);
}
self.enrolled
.borrow_mut()
.push((principal.into(), key.into()));
Ok(())
}
fn twin_csr(&self, twin_ctl: &str) -> Result<String, String> {
self.calls.borrow_mut().push(format!("csr {twin_ctl}"));
Ok("-----BEGIN CERTIFICATE REQUEST-----".into())
}
fn issue(&self, primary_ctl: &str, principal: &str, csr: &str) -> Result<Issued, String> {
self.calls
.borrow_mut()
.push(format!("issue {principal} at {primary_ctl}"));
if !self.enrolled.borrow().iter().any(|(p, _)| p == principal) {
return Err(format!("{principal} is not a member of group `twin`"));
}
assert!(csr.contains("REQUEST"));
Ok(Issued {
cert_pem: "CERT".into(),
issuer_pem: "CA".into(),
port: 50052,
host: "10.44.0.3".into(),
})
}
fn install(&self, twin_ctl: &str, issued: &Issued) -> Result<i64, String> {
self.calls.borrow_mut().push(format!("install {twin_ctl}"));
*self.installed.borrow_mut() = Some(issued.clone());
Ok(824)
}
}
/// ★ **The primary says this front's certify key was fed to it and is being
/// enrolled** (cov-E r3 2026-10-06, run 3: the primary's fed principals
/// took 4 m 47 s on a starved box): `Unavailable [key-enrolling]` is
/// EXPECTED however long into the loop — said at INFO, asked again within
/// [`EXPECTED_RETRY`] — so the enrolment is used the moment it lands.
#[test]
fn a_primary_enrolling_this_fronts_key_is_expected_at_any_time_and_asked_again_soon() {
let enrolling = format!(
"enrol: the primary at 10.44.0.2:50051 would not enrol gunnar-twin: the principal: Unavailable: {} this key was fed to this box by its installer and is being enrolled; ask again in a moment",
gunnar_grpc::KEY_ENROLLING
);
assert_eq!(tone(&enrolling, Duration::ZERO), Tone::Expected);
assert_eq!(
tone(&enrolling, NOT_FED_YET_FOR + Duration::from_secs(200)),
Tone::Expected,
"the primary's own word that it is enrolling: no window"
);
assert_eq!(
next_wait(
tone(&enrolling, Duration::from_secs(300)),
Duration::from_secs(30)
),
EXPECTED_RETRY
);
}
/// ★ **The fresh front's first certify reaches the twin before the twin
/// was re-fed this front's certify key** (stinkies u3-certify-before-twin-refed,
/// MEASURED estate-box3 life1 + d-life lf2 U3 and lf9 R3): the installer
/// feeds the front first and re-feeds the twin and the primary its offer
/// ~150 ms later, so the first `Bootstrap.BecomeTwin` is refused
/// `[bootstrap-unauthenticated]` — EXPECTED, said at INFO, asked again
/// within 2 s; past the window the same refusal is said as before.
#[test]
fn a_first_refusal_because_the_twin_was_not_fed_this_fronts_key_yet_is_expected_and_asked_again_soon(
) {
let unfed = "twin-key: the twin at 10.6.0.13:50051 gave no key: Unauthenticated: REFUSED [bootstrap-unauthenticated] Bootstrap.BecomeTwin needs `gunnar-authorization: gunnar-sshsig-v2 …`";
let primary_unfed = "enrol: the primary at 10.6.0.14:50051 would not enrol gunnar-twin: Unauthenticated: the credential did not verify against any registered key";
assert_eq!(tone(unfed, Duration::from_secs(0)), Tone::Expected);
assert_eq!(tone(primary_unfed, Duration::from_secs(5)), Tone::Expected);
assert_eq!(
tone(unfed, NOT_FED_YET_FOR + Duration::from_secs(1)),
Tone::Refused,
"minutes later it is not a race any more"
);
assert_eq!(tone("issue: the primary would not certify gunnar-twin: gunnar-twin is not a member of group `twin`", Duration::ZERO), Tone::Refused);
assert_eq!(
next_wait(Tone::Expected, Duration::from_secs(30)),
EXPECTED_RETRY
);
assert_eq!(
next_wait(Tone::Refused, Duration::from_secs(30)),
Duration::from_secs(30)
);
// Over the loop: refused once (not fed yet), then it takes — asked
// again within EXPECTED_RETRY, not after the planned 10 s.
struct Unfed(RefCell<u32>, Fake);
impl Boxes for Unfed {
fn twin_key(&self, twin_ctl: &str, primary: &str) -> Result<String, String> {
*self.0.borrow_mut() += 1;
if *self.0.borrow() == 1 {
return Err("Unauthenticated: REFUSED [bootstrap-unauthenticated] Bootstrap.BecomeTwin needs a fed key".into());
}
self.1.twin_key(twin_ctl, primary)
}
fn enrol(&self, a: &str, b: &str, c: &str) -> Result<(), String> {
self.1.enrol(a, b, c)
}
fn twin_csr(&self, a: &str) -> Result<String, String> {
self.1.twin_csr(a)
}
fn issue(&self, a: &str, b: &str, c: &str) -> Result<Issued, String> {
self.1.issue(a, b, c)
}
fn install(&self, a: &str, b: &Issued) -> Result<i64, String> {
self.1.install(a, b)
}
}
let boxes = Unfed(RefCell::new(0), Fake::default());
let rec = Mutex::new(Record::default());
let t0 = std::time::Instant::now();
assert!(run(
&boxes,
&Target::of(&door(true)).unwrap(),
&rec,
None,
[Duration::from_secs(10)]
));
assert!(
t0.elapsed() < Duration::from_secs(5),
"an expected refusal waited the planned {:?}",
t0.elapsed()
);
assert_eq!(rec.lock().unwrap().attempts, 2);
}
#[test]
fn no_target_before_the_verdict_and_the_verdict_not_the_slot_name_picks_the_twin() {
assert_eq!(Target::of(&door(false)), None);
let t = Target::of(&door(true)).unwrap();
assert_eq!(
t.twin_ctl, "10.44.0.2:50051",
"the twin is reached on its OVERLAY address (WireGuard-only)"
);
assert_eq!(t.primary_ctl, "10.44.0.3:50051");
assert_eq!(t.primary_for_twin, "10.44.0.3:50051", "BecomeTwin gets the primary's OVERLAY address, as the twin's own loop gives it under --wg-only");
}
/// 0.3: the installer's ledger is keyed by the PUBLIC address it dialled,
/// with the utility address beside it — the control planes are reached on
/// neither: over the tunnel, at each slot's overlay (WireGuard-only).
#[test]
fn a_ledger_keyed_by_the_public_address_reaches_the_control_planes_over_the_tunnel() {
use nordisk_pairing::installer::{CloseWhen, Plan, PlanVm};
use nordisk_pairing::join::{JoinRequest, Presented};
use nordisk_pairing::seed::{Kind, TunnelKind};
use std::net::Ipv4Addr;
let vm = |name: &str, last: u8, overlay: Ipv4Addr| PlanVm {
name: name.into(),
public: Ipv4Addr::new(95, 0, 0, last),
utility: Ipv4Addr::new(10, 13, 8, last),
overlay,
kind: Kind::Gunnar,
feed: Default::default(),
};
let plan = Plan {
estate: "gunnar.rs".into(),
tunnel: TunnelKind::Wireguard,
vms: vec![
vm("main", 2, nordisk_pairing::OVERLAY_BOX_A),
vm("twin", 3, nordisk_pairing::OVERLAY_BOX_B),
],
feed: Default::default(),
front: None,
port: 8474,
link_port: 51821,
budget_s: 60,
close_when: CloseWhen::Paired,
box_cert_valid_s: None,
front_waits_for: Vec::new(),
};
let mut d = plan.door().with_ca(
nordisk_pairing::mtls::InstallerCa::mint(
"gunnar.rs",
std::time::Duration::from_secs(3600),
)
.unwrap(),
);
let cap = |cores| {
Some(nordisk_pairing::elect::Capacity {
cores,
ram_bytes: 1 << 30,
data_bytes: 1 << 30,
disk_mib_s: 100,
})
};
let req = |cores| JoinRequest {
estate: "gunnar.rs".into(),
kind: Kind::Gunnar,
tunnel_pubkey: String::new(),
capacity: cap(cores),
csr_pem: nordisk_pairing::mtls::box_request().unwrap().0,
};
d.join(&req(4), &Presented::Bootstrap, Ipv4Addr::new(95, 0, 0, 2))
.unwrap();
d.join(&req(1), &Presented::Bootstrap, Ipv4Addr::new(95, 0, 0, 3))
.unwrap();
let t = Target::of(&d).expect("the verdict is in");
assert_eq!((t.twin.as_str(), t.primary.as_str()), ("twin", "main"));
assert_eq!(t.twin_ctl, "10.44.0.3:50051");
assert_eq!(t.primary_ctl, "10.44.0.2:50051");
assert_eq!(t.primary_for_twin, "10.44.0.2:50051");
}
#[test]
fn the_door_enrols_then_certifies_in_the_order_the_primary_demands() {
let t = Target::of(&door(true)).unwrap();
let fake = Fake::default();
let rec = Mutex::new(Record::default());
assert!(run(&fake, &t, &rec, None, []));
assert_eq!(
*fake.calls.borrow(),
[
"become 10.44.0.2:50051 of 10.44.0.3:50051",
"enrol gunnar-twin at 10.44.0.3:50051",
"csr 10.44.0.2:50051",
"issue gunnar-twin at 10.44.0.3:50051",
"install 10.44.0.2:50051",
]
);
assert_eq!(
fake.enrolled.borrow()[0].1,
"ssh-ed25519 AAAAtwin gunnar-twin",
"the twin's own key, trimmed"
);
assert_eq!(
fake.installed
.borrow()
.as_ref()
.map(|i| (i.port, i.issuer_pem.as_str())),
Some((50052, "CA"))
);
let r = rec.lock().unwrap().clone();
assert!(r.certified && r.why.is_empty() && r.attempts == 1);
assert_eq!(
r.replication, "10.44.0.3:50052",
"the host the primary reported (its tunnel address) wins"
);
let s = signal(&r, true, true);
assert!(
s.contains("twin_certified = true") && s.contains("twin_cert_days_left = 824"),
"{s}"
);
}
#[test]
fn a_primary_without_its_admin_yet_is_retried_and_the_signal_says_why_meanwhile() {
let t = Target::of(&door(true)).unwrap();
let fake = Fake {
admin_after: 2,
..Default::default()
};
let rec = Mutex::new(Record::default());
let p = std::env::temp_dir().join(format!("door-certify-{}.toml", std::process::id()));
// Out of waits after one retry: still refused, and said so by step.
assert!(!run(&fake, &t, &rec, Some(&p), [Duration::ZERO]));
let r = Record::load(&p).unwrap();
assert_eq!((r.certified, r.attempts), (false, 2));
assert!(
r.why.starts_with("enrol: the primary at 10.44.0.3:50051")
&& r.why.contains("Unauthenticated"),
"{}",
r.why
);
let s = signal(&r, true, true);
assert!(
s.contains("twin_certified = false")
&& s.contains("twin_certify_attempts = 2")
&& s.contains("Unauthenticated"),
"{s}"
);
assert!(
toml::from_str::<toml::Table>(&s).is_ok(),
"the signal lines stay TOML: {s}"
);
// The third attempt takes.
assert!(run(&fake, &t, &rec, Some(&p), []));
let r = Record::load(&p).unwrap();
assert!(r.certified && r.attempts == 3 && r.twin == "main" && r.primary == "twin");
let _ = std::fs::remove_file(&p);
}
/// **A loop for a pair the front no longer wants stops** (estate-box3 life1, R2 after
/// U3: row recharge-twin-certify-stale). The twin was laid again at new addresses and
/// the installer re-fed the front; a second loop started for the new pair, and the first
/// kept dialling the DEAD twin (`10.6.0.13 … gave no key: tcp connect error`) between
/// the new loop's attempts, for ever, writing its refusals over the new loop's record.
/// Here the re-feed lands while the old loop's attempt is under way: that attempt's
/// outcome is not written, and no further attempt is made.
#[test]
fn a_loop_whose_pair_the_front_no_longer_wants_stops_dialling_it() {
struct DeadTwin<'a> {
calls: RefCell<Vec<String>>,
wanted: &'a Mutex<Option<String>>,
moved_to: String,
}
impl Boxes for DeadTwin<'_> {
fn twin_key(&self, twin_ctl: &str, _: &str) -> Result<String, String> {
self.calls.borrow_mut().push(format!("become {twin_ctl}"));
// The installer re-feeds the front while this dial times out.
*self.wanted.lock().unwrap() = Some(self.moved_to.clone());
Err("could not be reached (Unavailable): tcp connect error".into())
}
fn enrol(&self, _: &str, _: &str, _: &str) -> Result<(), String> {
unreachable!()
}
fn twin_csr(&self, _: &str) -> Result<String, String> {
unreachable!()
}
fn issue(&self, _: &str, _: &str, _: &str) -> Result<Issued, String> {
unreachable!()
}
fn install(&self, _: &str, _: &Issued) -> Result<i64, String> {
unreachable!()
}
}
let at = |twin: &str| Target {
twin: "twin".into(),
primary: "main".into(),
twin_ctl: twin.into(),
primary_ctl: "10.6.0.14:50051".into(),
primary_for_twin: "10.6.0.14:50051".into(),
generation: String::new(),
};
let (old, new) = (at("10.6.0.13:50051"), at("10.6.0.15:50051"));
let wanted = Mutex::new(Some(old.key()));
let fake = DeadTwin {
calls: RefCell::new(Vec::new()),
wanted: &wanted,
moved_to: new.key(),
};
let rec = Mutex::new(Record::default());
assert!(
!run_while(
&fake,
&old,
&rec,
None,
std::iter::repeat_n(Duration::ZERO, 5),
&wanted
),
"the old pair is never certified"
);
assert_eq!(
*fake.calls.borrow(),
["become 10.6.0.13:50051"],
"one attempt at the dead twin, then the loop stops"
);
assert_eq!(
*rec.lock().unwrap(),
Record::default(),
"the superseded attempt writes nothing over the new pair's record"
);
// A loop whose pair is still wanted goes on as before.
let fake = Fake::default();
let wanted = Mutex::new(Some(Target::of(&door(true)).unwrap().key()));
assert!(run_while(
&fake,
&Target::of(&door(true)).unwrap(),
&Mutex::new(Record::default()),
None,
[],
&wanted
));
}
#[test]
fn a_twin_that_answers_no_key_line_is_refused_before_anything_is_enrolled() {
struct NoKey;
impl Boxes for NoKey {
fn twin_key(&self, _: &str, _: &str) -> Result<String, String> {
Ok(String::new())
}
fn enrol(&self, _: &str, _: &str, _: &str) -> Result<(), String> {
panic!("nothing is enrolled without a key")
}
fn twin_csr(&self, _: &str) -> Result<String, String> {
unreachable!()
}
fn issue(&self, _: &str, _: &str, _: &str) -> Result<Issued, String> {
unreachable!()
}
fn install(&self, _: &str, _: &Issued) -> Result<i64, String> {
unreachable!()
}
}
let e = once(&NoKey, &Target::of(&door(true)).unwrap()).unwrap_err();
assert!(e.starts_with("twin-key:"), "{e}");
}
#[test]
fn the_signal_before_the_election_and_without_a_console_key() {
let r = Record::default();
assert!(signal(&r, true, false).contains("waiting for the election"));
assert!(signal(&r, false, true).contains("without --console-key"));
assert_eq!(
record_path(Path::new("/var/lib/gunnar-pairing/door.toml")),
Path::new("/var/lib/gunnar-pairing/door.certify.toml")
);
let w: Vec<u64> = door_waits().take(5).map(|d| d.as_secs()).collect();
assert_eq!(w, [5, 10, 20, 30, 30]);
}
}