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