crates/gunnar-pairing-door/src/bin/gunnar-pairing-door.rs
//! `gunnar-pairing-door --ledger <ledger.toml> --relay-key <relay.key> [--console-key <path>] [--port <udp>]`
//! — **the front's part of an installation: the relay** (edda nordisk-pairing 0.3).
//!
//! **Since 0.4 the estate does not need this binary**: vm1 boots the FRONT
//! medium (`gunnar-front-installer-open`), the installer feeds it role
//! `front`, and `gunnar-front` runs the same relay and certification itself
//! (`gunnar_pairing_door::front`). Kept for a front laid the 0.3 way.
//!
//! Until 0.2 this was the installer's join door: the boxes dialled it over
//! mutual TLS and it admitted them, elected, fed. Since 0.3 (owner
//! 2026-09-24) the installer runs on the operator's machine and connects OUT
//! to every VM's bootstrap listener, so the front runs no door and holds no
//! installer CA. What is left is what only the front can be:
//!
//! * **the relay** — the WireGuard hub of the estate on udp/51820
//! (`relay.key`, 64 hex, 0600). Every box dials it (the installer FEEDS its
//! public key and endpoint as the `front` peer) and it relays between them.
//! It lists every box the installer ADMITTED, by its overlay /32, read from
//! the installer's ledger, which the operator's tooling pushes here as the
//! installer writes it — re-read every [`POLL`], so a box admitted mid-run
//! is listed within seconds;
//! * with `--console-key` (the front's `/opt/gunnar-ui/console_key`, the key
//! the primary was fed as its first admin): **the twin's certification**,
//! once the ledger carries the election's verdict — enrol the twin's key on
//! the primary and certify it for mutual-TLS replication
//! (`gunnar_pairing_door::certify`), retried until it takes; the outcome is
//! kept in `<ledger>.certify.toml`. The membership that lets the twin read
//! the primary's log is signed by an admin key, and the console key is the
//! one admin key that exists outside the primary.
use gunnar_pairing_door::certify::{self, Record, Target};
use gunnar_pairing_door::grpc::GrpcBoxes;
use nordisk_pairing::door::Door;
use nordisk_pairing::seed::Kind;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// How often the ledger is read again.
const POLL: Duration = Duration::from_secs(2);
/// The WireGuard port the installer feeds for the front.
const RELAY_PORT: u16 = 51820;
fn usage() -> ! {
eprintln!("usage: gunnar-pairing-door --ledger <ledger.toml> --relay-key <relay.key> [--console-key <path>] [--port <udp port, default {RELAY_PORT}>]");
std::process::exit(2)
}
fn fail(why: String) -> ! {
eprintln!("gunnar-pairing-door: {why}");
std::process::exit(1)
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let (mut ledger, mut relay_key_path, mut console_key, mut port) =
(None, None, None, RELAY_PORT);
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--ledger" => ledger = it.next().cloned().map(PathBuf::from),
"--relay-key" => relay_key_path = it.next().cloned().map(PathBuf::from),
"--console-key" => console_key = it.next().cloned().map(PathBuf::from),
"--port" => {
port = it
.next()
.and_then(|p| p.parse().ok())
.unwrap_or_else(|| usage())
}
_ => usage(),
}
}
let (Some(ledger), Some(relay_key_path)) = (ledger, relay_key_path) else {
usage()
};
let key = relay_key(&relay_key_path).unwrap_or_else(|e| fail(e));
let public =
wg_appliance_core::encode_key(&wg_appliance_core::keys::public_from_private_bytes(&key));
// A32-14, stays a task: the front's relay, held for the door's life.
let relay =
wg_appliance_core::hubrelay::HubRelay::spawn(port, key, nordisk_pairing::OVERLAY_FRONT)
.unwrap_or_else(|e| fail(format!("the relay could not bind udp/{port}: {e}")));
eprintln!("gunnar-pairing-door: relay on udp/{port} as {} ({public}); listing what the installer admits in {}", nordisk_pairing::OVERLAY_FRONT, ledger.display());
// The console key: given and unreadable is a refusal to start, not a
// front that quietly never certifies.
let console = console_key.as_ref().map(|p| {
gunnar_core::ssh_key::PrivateKey::read_openssh_file(p)
.unwrap_or_else(|e| fail(format!("--console-key {}: {e}", p.display())))
});
let record_path = certify::record_path(&ledger);
let record = Arc::new(Mutex::new(
Record::load(&record_path).unwrap_or_else(|e| fail(e)),
));
let mut certifying = false;
let mut listed: BTreeSet<String> = BTreeSet::new();
let mut said_front: Option<String> = None;
let mut said_missing = false;
loop {
match Door::load(&ledger) {
Ok(door) => {
said_missing = false;
check_front(&door, &public, &mut said_front);
for line in list_on_relay(&relay, &door, &mut listed) {
eprintln!("gunnar-pairing-door: {line}");
}
start_certify(
&door,
console.as_ref(),
&record,
&record_path,
&mut certifying,
);
}
Err(e) if !said_missing => {
eprintln!("gunnar-pairing-door: no ledger to read yet ({e}) — the relay lists nobody until the installer's ledger is here");
said_missing = true;
}
Err(_) => {}
}
std::thread::sleep(POLL);
}
}
/// The relay's private key (hex, 0600 — the operator's tooling wrote it at
/// lay). Malformed or absent is a refusal to start.
fn relay_key(path: &Path) -> Result<[u8; 32], String> {
let hex = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
parse_hex_key(hex.trim()).ok_or_else(|| format!("{}: not 64 hex digits", path.display()))
}
fn parse_hex_key(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let mut key = [0u8; 32];
for (i, b) in key.iter_mut().enumerate() {
*b = u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).ok()?;
}
Some(key)
}
/// The front's tunnel the installer FEEDS must be this relay's key, or every
/// box dials a key nobody answers to. Said (not fatal: the ledger is the
/// installer's and may be pushed again) once per distinct ledger key.
fn check_front(door: &Door, public: &str, said: &mut Option<String>) {
let fed = door
.front_tunnel()
.map(|f| f.tunnel_pubkey.trim().to_string());
if fed == *said {
return;
}
match &fed {
Some(k) if k != public => eprintln!(
"gunnar-pairing-door: REFUSED [door-front-key-mismatch] the installer feeds the front's tunnel key {k} and this relay runs as {public}: every box would dial a key nobody answers to — prepare the installer again with this relay's key"
),
Some(_) => eprintln!("gunnar-pairing-door: the installer feeds this relay's key to every box"),
None => eprintln!("gunnar-pairing-door: the ledger names no front tunnel — a WireGuard estate's boxes are fed no relay to dial"),
}
*said = fed;
}
/// Every admitted gunnar box on the relay, by its overlay /32 — each once.
/// Returns what was listed now.
fn list_on_relay(
relay: &wg_appliance_core::hubrelay::HubRelay,
door: &Door,
listed: &mut BTreeSet<String>,
) -> Vec<String> {
let mut said = Vec::new();
for (name, key, overlay) in to_list(door) {
if listed.contains(&key) {
continue;
}
let Ok(k) = wg_appliance_core::decode_key(&key) else {
said.push(format!(
"`{name}` presented a tunnel key that is not a WireGuard key — not listed"
));
listed.insert(key);
continue;
};
// The relay refuses a key or an address it already lists: either way
// it is there.
let _ = relay.add_peer(k, overlay);
said.push(format!("`{name}` listed on the relay as {overlay}"));
listed.insert(key);
}
said
}
/// Pure: (name, tunnel key, overlay) of every admitted gunnar box with a key.
fn to_list(door: &Door) -> Vec<(String, String, std::net::Ipv4Addr)> {
door.ledger
.admitted
.iter()
.filter(|(_, a)| !a.tunnel_pubkey.trim().is_empty())
.filter_map(|(name, a)| {
door.ledger
.slot_of(name)
.filter(|(_, s)| s.kind == Kind::Gunnar)
.map(|(_, s)| (name.clone(), a.tunnel_pubkey.trim().to_string(), s.overlay))
})
.collect()
}
/// The jera job that certifies the twin (A32-14).
const CERTIFY_JOB: &str = "certify-twin";
/// **Once the verdict exists, certify the twin** — a named jera job (A32-14).
/// Started once per process; a record that says certified is not redone.
fn start_certify(
door: &Door,
key: Option<&gunnar_core::ssh_key::PrivateKey>,
record: &Arc<Mutex<Record>>,
record_path: &Path,
started: &mut bool,
) {
if *started {
return;
}
let (Some(key), Some(target)) = (key, Target::of(door)) else {
return;
};
*started = true;
if record.lock().map(|r| r.certified).unwrap_or(false) {
eprintln!(
"gunnar-pairing-door: twin `{}` is already certified ({}), not redone",
target.twin,
record_path.display()
);
return;
}
let (key, record, path) = (key.clone(), Arc::clone(record), record_path.to_path_buf());
eprintln!("gunnar-pairing-door: the election is in — certifying twin `{}` ({}) against primary `{}` ({})", target.twin, target.twin_ctl, target.primary, target.primary_ctl);
let say: gunnar_common::jobs::Say = Arc::new(|l: &str| eprintln!("gunnar-pairing-door: {l}"));
let spawned =
gunnar_common::jobs::start(CERTIFY_JOB, Duration::from_secs(30), say, move || {
match GrpcBoxes::new(key.clone(), Duration::from_secs(15)) {
Ok(boxes) => {
certify::run(&boxes, &target, &record, Some(&path), certify::door_waits());
}
Err(e) => record.lock().unwrap_or_else(|p| p.into_inner()).why = e,
}
gunnar_common::jobs::Round::Done
});
if let Err(e) = spawned {
eprintln!("gunnar-pairing-door: the certify job could not start: {e}");
*started = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
use nordisk_pairing::installer::{CloseWhen, Plan, PlanVm};
use nordisk_pairing::join::{JoinRequest, Presented};
use nordisk_pairing::seed::TunnelKind;
use std::net::Ipv4Addr;
#[test]
fn the_relay_lists_every_admitted_gunnar_box_with_a_key_by_its_overlay() {
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 door = plan.door().with_ca(
nordisk_pairing::mtls::InstallerCa::mint("gunnar.rs", Duration::from_secs(3600))
.unwrap(),
);
assert!(to_list(&door).is_empty(), "nobody admitted yet");
let req = |k: &str| JoinRequest {
estate: "gunnar.rs".into(),
kind: Kind::Gunnar,
tunnel_pubkey: k.into(),
capacity: None,
csr_pem: nordisk_pairing::mtls::box_request().unwrap().0,
};
door.join(
&req("xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg="),
&Presented::Bootstrap,
Ipv4Addr::new(95, 0, 0, 2),
)
.unwrap();
door.join(&req(""), &Presented::Bootstrap, Ipv4Addr::new(95, 0, 0, 3))
.unwrap();
let listed = to_list(&door);
assert_eq!(
listed,
vec![(
"main".to_string(),
"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=".to_string(),
nordisk_pairing::OVERLAY_BOX_A
)],
"a box with no tunnel key yet is not listed"
);
}
#[test]
fn the_relay_key_is_64_hex_or_refused() {
assert_eq!(parse_hex_key(&"ab".repeat(32)).unwrap()[0], 0xab);
assert!(parse_hex_key("abc").is_none());
assert!(parse_hex_key(&"zz".repeat(32)).is_none());
}
}