Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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());
    }
}