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

nordisk/gunnar

deploy/upcloud/src/banner.rs

//! **The verification, and it is the only evidence this procedure trusts.**
//!
//! Every UpCloud call in a re-image can answer `200 OK` while the appliance is a
//! brick: the API reports on the *hypervisor*, not on the thing running inside
//! it. So the verdict is taken from the box itself — open a TCP connection to
//! its git-SSH port and read the identification string it sends first:
//!
//! ```text
//! SSH-2.0-gunnar_1.3.0 v1.3.0-48-g426c938
//! ```
//!
//! There is no shell in this appliance, so `gunnar --version` cannot be run in
//! it. The banner is what it says about itself, and it is the only thing that
//! can say which build actually came back up. **No banner is a FAILURE**, never
//! a warning.
//!
//! # GATE-IDENTITY, ported
//!
//! `<ops>/deploy-appliance.sh` learned on 2026-08-31 that a gate testing a
//! *different artefact* than the one being deployed "is not a gate, it is a
//! decoration": its console door ran a `gunnar-cli` twenty commits behind the
//! ISO, so a review gate that had shipped was proved absent. Its answer was
//! GATE-IDENTITY — assert the ISO's version equals the version the booted
//! appliance reports.
//!
//! [`identity_gate`] is that check on this side of the fence: `--expect-version`
//! is compared against the banner of the box that came back. When no expected
//! version is given the gate is **skipped by name**, printed as a skip, and
//! recorded as one in the receipt — because the one thing GATE-IDENTITY's
//! history proves is that a check nobody can see not running is worse than no
//! check at all.
//!
//! # Why this reads a socket instead of calling `installtest`
//!
//! `installtest/src/lib.rs:1186` has the same fifteen lines
//! (`ssh_banner`), and `installtest/src/iso.rs:407` has the same identity
//! assertion. Reaching them would mean a path dependency on
//! `gunnar-installtest`, which pulls draupnir, tunnr-vm, korp-installer and
//! QEMU wiring into a tool whose entire job is four HTTPS calls and one TCP
//! read — and `ssh_banner` is `pub(crate)` there, so it would also have to be
//! promoted. The twin is named here on purpose: if a third caller ever appears,
//! this belongs in edda as a leaf, next to `nornir-hash`.

use std::io::Read as _;
use std::net::{Shutdown, TcpStream, ToSocketAddrs};
use std::time::Duration;

/// Reading the banner, injectable — so "no banner ⇒ FAIL" is provable without a
/// live appliance, and so the install wait can be driven by a probe that answers
/// on the seventh try.
pub trait BannerProbe {
    /// `Ok(banner)`, or `Err(why)` when nothing answered.
    fn banner(&self, addr: &str) -> Result<String, String>;

    /// **The SSH host key the box presented, as `SHA256:…`.**
    ///
    /// # Why the receipt has to carry this
    ///
    /// `known_hosts` RECORDS NO PROVENANCE. A line in it written from a key
    /// taken off the wire by `ssh-keyscan` and a line written from a key read
    /// off an install console are the same bytes, for ever after; nothing in
    /// the file, and nothing `ssh` can ask it, tells the two apart. So a forge
    /// push that "verified the host key" means exactly as much as the WAY that
    /// line got there, and on this estate the way was scanning: eight
    /// `ssh-keyscan`-shaped comment lines for `[git.gunnar.rs]:2222` are in
    /// this laptop's `known_hosts` history (MEASURED 2026-09-23).
    ///
    /// It was scanned because the fingerprint was published on a channel that
    /// does not exist in production. `gunnar_server::serve` logs it at startup
    /// through `tracing`, i.e. to stderr, i.e. to the serial console — and the
    /// UpCloud shape is booted with `-serial none`, so in production that
    /// emission reaches nobody. Only the throwaway installtest gate VMs, which
    /// DO have a serial line, ever had it.
    ///
    /// This is the other end of that: the install itself already opens a
    /// session with the box to read its banner, and an SSH server presents its
    /// host key in the key exchange, before authentication and before anything
    /// else it will ever say. Reading it HERE means the fingerprint lands in
    /// the reinstall receipt — a record written BY the install, naming the box
    /// it was aimed at and the moment it answered — instead of being taken
    /// later off whatever is listening on `:2222`.
    ///
    /// Default: this probe does not speak SSH, so it has no host key to
    /// report, and it says which probe it is rather than returning an empty
    /// string. A receipt field that is blank because nobody looked must never
    /// be confusable with one that is blank because the box presented nothing.
    fn host_key_fingerprint(&self, addr: &str) -> Result<HostKey, String> {
        Err(format!(
            "this probe reads {addr} without an SSH key exchange, so no host key was ever \
             presented to it"
        ))
    }
}

/// **The host key a box presented, in both the forms a record needs.**
///
/// `fingerprint` is what a human compares and what an error message quotes.
/// `openssh` is the key ITSELF — `ssh-ed25519 AAAA…` — and it is here because a
/// fingerprint is a one-way hash: a `known_hosts` line cannot be built from
/// one. Recording only the fingerprint would leave the consumer of this receipt
/// no choice but to go and fetch the key off the port, which is the rumour this
/// whole field exists to replace.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostKey {
    /// `SHA256:…`, as `ssh` prints it.
    pub fingerprint: String,
    /// One line, `<algo> <base64>` — exactly the two columns a `known_hosts`
    /// entry carries after the name column.
    pub openssh: String,
}

/// The real one: connect, read, hang up.
pub struct TcpBanner {
    pub connect_timeout: Duration,
    pub read_timeout: Duration,
}

impl Default for TcpBanner {
    fn default() -> Self {
        TcpBanner {
            connect_timeout: Duration::from_secs(5),
            read_timeout: Duration::from_secs(8),
        }
    }
}

impl BannerProbe for TcpBanner {
    /// The real host-key read: one key exchange, abandoned at the key.
    fn host_key_fingerprint(&self, addr: &str) -> Result<HostKey, String> {
        self.read_host_key(addr)
    }

    fn banner(&self, addr: &str) -> Result<String, String> {
        let sock = addr
            .to_socket_addrs()
            .map_err(|e| format!("{addr} does not resolve: {e}"))?
            .next()
            .ok_or_else(|| format!("{addr} resolved to nothing"))?;
        let mut s = TcpStream::connect_timeout(&sock, self.connect_timeout)
            .map_err(|e| format!("connect {addr}: {e}"))?;
        s.set_read_timeout(Some(self.read_timeout))
            .map_err(|e| format!("set_read_timeout: {e}"))?;
        let mut buf = [0u8; 128];
        let n = s
            .read(&mut buf)
            .map_err(|e| format!("read from {addr}: {e}"))?;
        let _ = s.shutdown(Shutdown::Both);
        if n == 0 {
            return Err(format!("{addr} accepted and then closed without a banner"));
        }
        Ok(String::from_utf8_lossy(&buf[..n]).trim_end().to_string())
    }
}

// ── the host key, off the same box in the same session ──────────────────────

/// The client that exists to be shown a host key and to refuse it.
///
/// It records the fingerprint the server presented in the key exchange and
/// then answers **no**, which makes `russh` abandon the connection before a
/// single authentication packet is written. That is deliberate and it is the
/// whole shape of this reader: nothing is offered to the box, nothing is
/// trusted from it, and no session is ever established. The only thing that
/// crosses is the key the server itself put on the wire, which is the fact the
/// receipt is being written to hold.
struct KeyWitness {
    seen: std::sync::Arc<std::sync::Mutex<Option<HostKey>>>,
}

impl russh::client::Handler for KeyWitness {
    type Error = russh::Error;

    async fn check_server_key(
        &mut self,
        server_key: &russh::keys::PublicKeyOrCertificate,
    ) -> Result<bool, Self::Error> {
        // russh 0.63 hands a host CERTIFICATE through the same callback; the key it
        // certifies is the host key a `known_hosts` line names.
        let certified;
        let server_public_key = match server_key {
            russh::keys::PublicKeyOrCertificate::PublicKey { key, .. } => key,
            russh::keys::PublicKeyOrCertificate::Certificate(cert) => {
                certified = russh::keys::ssh_key::PublicKey::from(cert.public_key().clone());
                &certified
            }
        };
        if let Ok(mut slot) = self.seen.lock() {
            // Both forms, off the SAME key object, in one place: a fingerprint
            // and a key that could disagree would be worse than either alone.
            // `to_openssh` renders `<algo> <base64>` with no comment, which is
            // exactly the two columns a `known_hosts` line needs.
            if let Ok(openssh) = server_public_key.to_openssh() {
                *slot = Some(HostKey {
                    fingerprint: server_public_key
                        .fingerprint(russh::keys::HashAlg::Sha256)
                        .to_string(),
                    openssh: openssh.trim().to_string(),
                });
            }
        }
        // NEVER true. This reader has no business completing a handshake, and
        // an `Ok(true)` here would be `StrictHostKeyChecking=no` wearing a
        // different coat: a client that accepts whatever answered is exactly
        // the thing whose output nobody can tell from a scan.
        Ok(false)
    }
}

impl TcpBanner {
    /// One SSH key exchange against `addr`, for the host key and nothing else.
    ///
    /// `russh` is already in this tree — `nornir-git`'s transport is built on
    /// it and `src/front.rs` dials the front through it — so this is the
    /// constellation's SSH client reading the constellation's SSH server, not
    /// a second implementation of anything. The handshake is abandoned at the
    /// key check (see [`KeyWitness`]), so the cost is one TCP connection and
    /// one KEX.
    fn read_host_key(&self, addr: &str) -> Result<HostKey, String> {
        use std::sync::{Arc, Mutex};
        let seen: Arc<Mutex<Option<HostKey>>> = Arc::new(Mutex::new(None));
        let witness = KeyWitness {
            seen: Arc::clone(&seen),
        };
        let budget = self.connect_timeout + self.read_timeout;
        let mut config = russh::client::Config::default();
        config.inactivity_timeout = Some(budget);
        let config = Arc::new(config);
        let addr_owned = addr.to_string();

        let rt = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .map_err(|e| format!("building the runtime for the host-key read: {e}"))?;
        // Everything russh produces is dropped INSIDE the runtime: a
        // `client::Handle` dropped outside one panics with "there is no
        // reactor running", the bug `nornir-git`'s transport found once.
        let dialled = rt.block_on(async move {
            match tokio::time::timeout(
                budget,
                russh::client::connect(config, addr_owned.clone(), witness),
            )
            .await
            {
                Err(_) => Err(format!(
                    "no SSH key exchange with {addr_owned} within {budget:?}"
                )),
                Ok(Ok(_handle)) => Ok(()),
                Ok(Err(e)) => Err(format!("{e}")),
            }
        });

        let presented = seen.lock().ok().and_then(|s| s.clone());
        match presented {
            // The refusal above is the EXPECTED end of a successful read, so
            // the error `russh` returned is not reported when the key arrived.
            Some(key) => Ok(key),
            None => Err(match dialled {
                Ok(()) => format!(
                    "{addr} completed a key exchange and presented no host key, which no SSH \
                     server can do"
                ),
                Err(why) => format!(
                    "{addr} presented no SSH host key: {why}. A banner is NOT a host key — \
                     anything can print an identification string, and the key is what is \
                     signed for."
                ),
            }),
        }
    }
}

/// The build reference off a banner: `SSH-2.0-gunnar_1.3.0 v1.3.0-48-g426c938`
/// → `v1.3.0-48-g426c938`.
///
/// The comments field (everything after the first space) is read rather than the
/// `gunnar_1.3.0` product version, and that is deliberate: every build before
/// `008d74b` said `SSH-2.0-gunnar_0.1.0` no matter which commit it was mastered
/// from, so the version alone cannot tell two builds apart. `installtest`'s
/// `assert_banner_names` reads the same field for the same reason.
pub fn build_reference(banner: &str) -> Option<&str> {
    banner.split_whitespace().nth(1)
}

/// Is this gunnar at all, and — when an expectation was declared — is it the
/// gunnar that was supposed to come back?
pub fn identity_gate(banner: &str, expect: Option<&str>) -> Result<(), String> {
    if !is_gunnar(banner) {
        return Err(format!(
            "the listener answered {banner:?}, which is not gunnar's own SSH server. Either \
             the appliance did not come back and something else on that address answered, or \
             the install laid down a different product."
        ));
    }
    let Some(want) = expect else {
        return Ok(());
    };
    if !banner.contains(want) {
        return Err(format!(
            "GATE-IDENTITY: the appliance came back as {banner:?}, which does not name the \
             expected build {want}. The box is UP and it is the WRONG BUILD — either the ISO \
             was mastered from another commit, or a stale medium was uploaded. This is the \
             defect deploy-appliance.sh found on 2026-08-31, where a gate passed against a \
             binary twenty commits behind the ISO."
        ));
    }
    Ok(())
}

// ── the control-plane witness: `--verify-control` ───────────────────────────
//
// A box started `gunnar serve --role twin` binds its forge listener to
// loopback: there is no banner to read, and an install that worked would fail
// its verify on `verify_addr`. `--verify-control HOST:PORT` reads the same two
// facts off `Bootstrap.Status` instead — the build stamp (the banner's comments
// field, `gunnar_common::BUILD_REF`) and the role — through the FRONT's
// `gunnar control status`, because a utility address is not reachable from the
// operator's laptop. The answer is folded into one identity line so every
// consumer of a banner (the loop, the verify, GATE-IDENTITY, the receipt) reads
// it unchanged:
//
// ```text
// gunnar-control v1.4.0-167-ga5db99c role=twin
// ```

/// The first token of an identity read off the control plane.
pub const CONTROL_PREFIX: &str = "gunnar-control";

/// Is this identity gunnar — a banner, or a control-plane identity line?
pub fn is_gunnar(identity: &str) -> bool {
    identity.starts_with("SSH-2.0-gunnar") || identity.starts_with(&format!("{CONTROL_PREFIX} "))
}

/// `gunnar control status` output → `gunnar-control <build> role=<role>`.
///
/// A CLI or server older than the build stamp prints no `build` line or
/// `unreported`; that is an `Err` naming which, never an identity with a hole —
/// GATE-IDENTITY against a blank would be skipped by accident.
pub fn control_identity(status_output: &str) -> Result<String, String> {
    let field = |name: &str| {
        status_output.lines().find_map(|l| {
            let l = l.trim();
            let rest = l.strip_prefix(name)?;
            rest.starts_with(char::is_whitespace)
                .then(|| rest.trim().to_string())
        })
    };
    if !status_output
        .lines()
        .any(|l| l.trim_start().starts_with("control plane"))
    {
        return Err(format!(
            "`gunnar control status` did not answer as a control plane: {:?}",
            status_output.lines().last().unwrap_or("").trim()
        ));
    }
    let build = field("build").ok_or(
        "the front's `gunnar control status` prints no `build` line — its gunnar CLI predates the \
         build stamp on Bootstrap.Status; refresh it (`cargo xtask front --apply --cli-only` in ops)",
    )?;
    if build.is_empty() || build.starts_with("unreported") {
        return Err(format!(
            "the control plane reports no build ({build:?}) — a server older than the build stamp"
        ));
    }
    let role = field("role").filter(|r| !r.is_empty() && !r.starts_with("unreported")).ok_or_else(|| {
        format!("the control plane names build {build} and no role — a server older than the role on Bootstrap.Status")
    })?;
    Ok(format!("{CONTROL_PREFIX} {build} role={role}"))
}

/// The role in a control-plane identity line; `None` for an SSH banner.
pub fn identity_role(identity: &str) -> Option<&str> {
    identity
        .strip_prefix(CONTROL_PREFIX)?
        .split_whitespace()
        .find_map(|t| t.strip_prefix("role="))
}

/// `--expect-role`: the box must answer the role it was installed as.
pub fn role_gate(identity: &str, expect: Option<&str>) -> Result<(), String> {
    let Some(want) = expect else { return Ok(()) };
    match identity_role(identity) {
        Some(r) if r == want => Ok(()),
        Some(r) => Err(format!(
            "GATE-ROLE: the box answered role `{r}` where `{want}` was installed — the medium came \
             up as something other than what it was composed as"
        )),
        None => Err(format!(
            "GATE-ROLE: {identity:?} carries no role; --expect-role {want} needs --verify-control"
        )),
    }
}

/// **The control-plane probe**: `gunnar control status --at <addr>` run on the
/// front, folded by [`control_identity`]. A [`BannerProbe`] so the install
/// loop, the verify and the gates take it unchanged.
pub struct ControlProbe<R: crate::front::Remote> {
    pub remote: std::cell::RefCell<R>,
}

impl<R: crate::front::Remote> ControlProbe<R> {
    pub fn new(remote: R) -> ControlProbe<R> {
        ControlProbe {
            remote: std::cell::RefCell::new(remote),
        }
    }
}

impl<R: crate::front::Remote> BannerProbe for ControlProbe<R> {
    /// **A control-plane box has no host key to present HERE, and that is a
    /// fact about the box, not a gap in this tool.** `--verify-control` exists
    /// because a twin binds its forge listener to LOOPBACK: there is no SSH
    /// server on the address this procedure can reach, so there is no key
    /// exchange to hold one. The refusal says so by name, and the receipt
    /// carries the sentence rather than a blank.
    fn host_key_fingerprint(&self, addr: &str) -> Result<HostKey, String> {
        Err(format!(
            "{addr} was verified through the control plane (--verify-control), which reads \
             Bootstrap.Status and never opens an SSH session — a box whose forge listener is \
             on loopback presents its host key to nobody outside it"
        ))
    }

    fn banner(&self, addr: &str) -> Result<String, String> {
        let mut r = self.remote.borrow_mut();
        let out = crate::front::run(
            &mut *r,
            &format!("Bootstrap.Status on {addr}, from the front"),
            &format!("gunnar control status --at {addr}"),
            30,
        )?;
        control_identity(&out)
    }
}

/// The verify the live verb runs: the banner on `verify_addr`, or — with
/// `--verify-control` — the control plane through the front.
pub enum Verifier<R: crate::front::Remote> {
    Banner(TcpBanner),
    Control(ControlProbe<R>),
}

impl<R: crate::front::Remote> BannerProbe for Verifier<R> {
    fn banner(&self, addr: &str) -> Result<String, String> {
        match self {
            Verifier::Banner(b) => b.banner(addr),
            Verifier::Control(c) => c.banner(addr),
        }
    }

    fn host_key_fingerprint(&self, addr: &str) -> Result<HostKey, String> {
        match self {
            Verifier::Banner(b) => b.host_key_fingerprint(addr),
            Verifier::Control(c) => c.host_key_fingerprint(addr),
        }
    }
}

/// A banner probe with a script: it fails `silent_for` times and then answers.
///
/// `silent_for: u32::MAX` is the appliance that never comes back, which is the
/// case the verify step exists for. It lived in `fake.rs` beside the scripted
/// UpCloud transport until that was retired (2026-09-21); a banner is not an
/// UpCloud call, so it stays in the crate for the flow tests to reach.
pub struct ScriptedBanner {
    pub banner: String,
    pub silent_for: u32,
    calls: std::cell::Cell<u32>,
}

impl ScriptedBanner {
    pub fn answering_after(silent_for: u32, banner: &str) -> ScriptedBanner {
        ScriptedBanner {
            banner: banner.to_string(),
            silent_for,
            calls: std::cell::Cell::new(0),
        }
    }

    /// Never answers.
    pub fn silent() -> ScriptedBanner {
        ScriptedBanner::answering_after(u32::MAX, "")
    }

    pub fn calls(&self) -> u32 {
        self.calls.get()
    }
}

impl BannerProbe for ScriptedBanner {
    fn banner(&self, addr: &str) -> Result<String, String> {
        let n = self.calls.get();
        self.calls.set(n + 1);
        if n < self.silent_for {
            Err(format!("connect {addr}: Connection refused (os error 111)"))
        } else {
            Ok(self.banner.clone())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const LIVE: &str = "SSH-2.0-gunnar_1.3.0 v1.3.0-48-g426c938";

    // ── the host key: what the box PRESENTED, not what a file remembers ─────

    /// A deterministic ed25519 host key, so the test names the fingerprint it
    /// expects by DERIVING it from the key the server was given rather than by
    /// quoting a constant somebody could keep in step by hand.
    fn test_host_key(seed: u8) -> russh::keys::PrivateKey {
        use russh::keys::ssh_key::private::Ed25519Keypair;
        russh::keys::PrivateKey::from(Ed25519Keypair::from_seed(&[seed; 32]))
    }

    /// A russh server that answers one connection and does nothing else.
    struct OneShot;

    impl russh::server::Handler for OneShot {
        type Error = russh::Error;
    }

    /// Start an SSH server on loopback with `key` as its host key; returns the
    /// address it bound. The listener serves exactly one connection, which is
    /// all a host-key read needs.
    fn ssh_server(key: russh::keys::PrivateKey) -> String {
        let (tx, rx) = std::sync::mpsc::channel();
        std::thread::spawn(move || {
            let rt = tokio::runtime::Builder::new_current_thread()
                .enable_all()
                .build()
                .expect("server runtime");
            rt.block_on(async move {
                let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
                    .await
                    .expect("bind");
                tx.send(listener.local_addr().expect("addr").to_string())
                    .expect("hand the address back");
                let mut config = russh::server::Config::default();
                config.keys.push(key);
                config.inactivity_timeout = Some(Duration::from_secs(5));
                let config = std::sync::Arc::new(config);
                if let Ok((sock, _)) = listener.accept().await {
                    // `run_stream` starts the session in a background task and
                    // hands back a handle; the session is AWAITED here, because
                    // dropping the handle (or letting this runtime end) tears
                    // the connection down before the key exchange has run and
                    // the client sees `early eof`. Measured, first run.
                    //
                    // The client refuses the key and hangs up, so this always
                    // ends in an error; the server is the fixture, not the
                    // thing under test.
                    if let Ok(session) = russh::server::run_stream(config, sock, OneShot).await {
                        let _ = session.await;
                    }
                }
            });
        });
        rx.recv_timeout(Duration::from_secs(10))
            .expect("the test SSH server never bound a port")
    }

    /// **The whole point, end to end over a real key exchange.**
    ///
    /// Red-first: with `check_server_key` recording nothing — or recording a
    /// constant — this fails, because the expectation is computed from the key
    /// the server was handed and from nowhere else. A fingerprint that does
    /// not come off the wire cannot pass this test, which is exactly the
    /// property `known_hosts` does not have.
    #[test]
    fn the_fingerprint_is_the_key_the_box_presented() {
        let key = test_host_key(7);
        let expected = key
            .public_key()
            .fingerprint(russh::keys::HashAlg::Sha256)
            .to_string();
        let expected_openssh = key.public_key().to_openssh().expect("openssh");
        assert!(expected.starts_with("SHA256:"), "{expected}");
        let addr = ssh_server(key);
        let seen = TcpBanner::default()
            .host_key_fingerprint(&addr)
            .unwrap_or_else(|e| panic!("no host key off {addr}: {e}"));
        assert_eq!(seen.fingerprint, expected);
        // The key ITSELF, not only its hash: a `known_hosts` line cannot be
        // built from a fingerprint, and a receipt that recorded only the hash
        // would send its reader back to the port for the bytes.
        assert_eq!(
            seen.openssh, expected_openssh,
            "the recorded key must be the key the server serves"
        );

        // And a DIFFERENT key gives a different answer — the reader is reading,
        // not echoing.
        let other = test_host_key(9);
        let other_expected = other
            .public_key()
            .fingerprint(russh::keys::HashAlg::Sha256)
            .to_string();
        assert_ne!(other_expected, expected);
        let other_addr = ssh_server(other);
        assert_eq!(
            TcpBanner::default()
                .host_key_fingerprint(&other_addr)
                .unwrap()
                .fingerprint,
            other_expected
        );
    }

    /// **A banner is not a host key**, and this is the confusion the defect was
    /// made of: the receipt held an SSH identification string and nothing
    /// else, and anything at all can print one of those.
    #[test]
    fn something_that_only_prints_a_banner_yields_no_host_key() {
        use std::io::Write as _;
        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
        let addr = listener.local_addr().expect("addr").to_string();
        std::thread::spawn(move || {
            if let Ok((mut sock, _)) = listener.accept() {
                // The exact shape the receipt used to record as its proof.
                let _ = sock.write_all(b"SSH-2.0-gunnar_1.4.0 v1.4.0-611-g5d152a3\r\n");
                let _ = sock.flush();
                std::thread::sleep(Duration::from_millis(200));
            }
        });
        let probe = TcpBanner {
            connect_timeout: Duration::from_secs(2),
            read_timeout: Duration::from_secs(2),
        };
        // The banner reads fine off exactly the same socket shape...
        let err = probe.host_key_fingerprint(&addr).unwrap_err();
        // ...and the host key does not, in words that say why.
        assert!(err.contains("presented no SSH host key"), "{err}");
        assert!(err.contains("A banner is NOT a host key"), "{err}");
    }

    /// A probe that never opens an SSH session says so BY NAME. A receipt
    /// whose fingerprint is missing must always carry the sentence explaining
    /// which probe could not get one, because a blank field and a blank
    /// `known_hosts` provenance are the same unreadable thing.
    #[test]
    fn a_probe_that_speaks_no_ssh_refuses_by_name() {
        let scripted = ScriptedBanner::answering_after(0, LIVE);
        let err = scripted
            .host_key_fingerprint("10.13.8.122:2222")
            .unwrap_err();
        assert!(err.contains("10.13.8.122:2222"), "{err}");
        assert!(err.contains("no host key was ever presented"), "{err}");

        let remote = crate::front::ScriptedRemote::default()
            .reply("gunnar control status --at 10.13.8.122:50051", TWIN_STATUS);
        let control = ControlProbe::new(remote);
        let err = control
            .host_key_fingerprint("10.13.8.122:50051")
            .unwrap_err();
        assert!(err.contains("--verify-control"), "{err}");
        assert!(err.contains("loopback"), "{err}");
    }

    #[test]
    fn the_build_reference_is_the_comments_field() {
        assert_eq!(build_reference(LIVE), Some("v1.3.0-48-g426c938"));
        assert_eq!(build_reference("SSH-2.0-gunnar_1.3.0"), None);
    }

    #[test]
    fn a_banner_that_is_not_gunnar_is_refused_and_quoted() {
        let err = identity_gate("SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13", None).unwrap_err();
        assert!(err.contains("OpenSSH"), "{err}");
        assert!(err.contains("not gunnar's own SSH server"), "{err}");
    }

    /// Red-first, seen with the gate written as `banner.starts_with(...)` only:
    /// the wrong-build banner passed, and the whole point of GATE-IDENTITY is
    /// that a box which is UP but WRONG is the failure that hides.
    #[test]
    fn a_live_box_carrying_the_wrong_build_fails_the_gate() {
        let err = identity_gate(LIVE, Some("v1.3.0-71-gdeadbee")).unwrap_err();
        assert!(err.contains("GATE-IDENTITY"), "{err}");
        assert!(err.contains("v1.3.0-71-gdeadbee"), "{err}");
        assert!(err.contains("WRONG BUILD"), "{err}");
    }

    #[test]
    fn the_expected_build_passes() {
        identity_gate(LIVE, Some("v1.3.0-48-g426c938")).unwrap();
        identity_gate(LIVE, None).unwrap();
    }

    // ── --verify-control ────────────────────────────────────────────────────

    /// What the front's refreshed `gunnar control status` prints for a fresh twin.
    const TWIN_STATUS: &str = "control plane  10.13.8.122:50051\nbuild          v1.4.0-167-ga5db99c\n\
                               role           twin\nbootstrap      OPEN — no identity has ever been enrolled here\n\
                               principals     0\nkeys           0\ngit            serving";
    /// The v1.4.0-129 CLI on the front today: no build line at all.
    const OLD_CLI_STATUS: &str =
        "control plane  10.13.8.122:50051\nbootstrap      OPEN — no identity\nprincipals     0";

    #[test]
    fn the_control_plane_status_folds_into_one_identity_line() {
        let id = control_identity(TWIN_STATUS).unwrap();
        assert_eq!(id, "gunnar-control v1.4.0-167-ga5db99c role=twin");
        assert!(is_gunnar(&id));
        assert_eq!(
            build_reference(&id),
            Some("v1.4.0-167-ga5db99c"),
            "the receipt's build_reference reads it"
        );
        assert_eq!(identity_role(&id), Some("twin"));
        assert_eq!(identity_role(LIVE), None);
        identity_gate(&id, Some("v1.4.0-167-ga5db99c")).unwrap();
        role_gate(&id, Some("twin")).unwrap();
    }

    /// Red-first: each way the control plane cannot say who it is, by name.
    #[test]
    fn a_control_plane_that_cannot_name_its_build_or_role_is_refused() {
        let e = control_identity(OLD_CLI_STATUS).unwrap_err();
        assert!(
            e.contains("no `build` line") && e.contains("--cli-only"),
            "{e}"
        );
        let old_server = TWIN_STATUS.replace(
            "v1.4.0-167-ga5db99c",
            "unreported (a server older than the build stamp on Bootstrap.Status)",
        );
        assert!(control_identity(&old_server)
            .unwrap_err()
            .contains("reports no build"));
        let no_role = TWIN_STATUS.replace("role           twin", "role           unreported");
        assert!(control_identity(&no_role).unwrap_err().contains("no role"));
        assert!(control_identity("error: unrecognized subcommand")
            .unwrap_err()
            .contains("did not answer"));
        let id = control_identity(TWIN_STATUS).unwrap();
        assert!(identity_gate(&id, Some("v1.4.0-168-gdeadbee"))
            .unwrap_err()
            .contains("WRONG BUILD"));
        assert!(role_gate(&id, Some("primary"))
            .unwrap_err()
            .contains("GATE-ROLE"));
        assert!(role_gate(LIVE, Some("twin"))
            .unwrap_err()
            .contains("needs --verify-control"));
    }

    #[test]
    fn the_control_probe_asks_the_front_and_nothing_else() {
        let remote = crate::front::ScriptedRemote::default()
            .reply("gunnar control status --at 10.13.8.122:50051", TWIN_STATUS);
        let probe = ControlProbe::new(remote);
        assert_eq!(
            probe.banner("10.13.8.122:50051").unwrap(),
            "gunnar-control v1.4.0-167-ga5db99c role=twin"
        );
        let r = probe.remote.borrow();
        assert_eq!(r.commands.len(), 1);
        assert!(
            r.ran("gunnar control status --at 10.13.8.122:50051"),
            "{:?}",
            r.commands
        );
        drop(r);
        let failing = ControlProbe::new(
            crate::front::ScriptedRemote::default()
                .fails("gunnar control status", "connection refused"),
        );
        assert!(failing
            .banner("10.13.8.122:50051")
            .unwrap_err()
            .contains("DID NOT SUCCEED"));
    }
}