crates/gunnar-server/tests/tls_transition.rs
//! **Moving a control plane onto mutual TLS: the refusal, the warning, and the
//! one state where `gunnar serve` will not start at all.**
//!
//! The review's note says `--grpc-tls-self` cannot be the default because
//! bootstrap needs plaintext first. Everything the transition needs already
//! shipped — `Bootstrap.Enroll` hands out a client certificate, the CLI dials
//! `https://` when it finds one, `gunnar principal add --csr` issues the rest
//! — so what was missing was never the machinery. It was that **the flip had
//! no preflight**: nothing stopped an operator turning TLS on at the one
//! moment it produces a listener nobody can reach, and nothing said, before
//! the restart, who would stop being able to connect.
//!
//! `gunnar_server::tls_preflight` is that check, and this file drives it into
//! each of its three verdicts, then proves the refusal is real against a
//! **live `Server::start`** — both directions on the same code path, because a
//! test that only asserted the refusal would pass just as well against a
//! preflight that refused everything.
//!
//! # How each guard here was seen red
//!
//! | mutation | what went red |
//! |---|---|
//! | `refusal()` made to return `None` always | the two `Refuse` arms and the live `Server::start` refusal |
//! | `verdict()` made to return `Verdict::Refuse` always | `…_is_ready_when_…` and `…_warns_and_names_…`, and the live "starts once enrolled" half |
//! | `locked_out()` made `false` always | `…_warns_and_names_…` and `an_expired_certificate_is_not_a_live_one` |
//! | the `key.algo == CERTIFICATE_ALGO` arm made to count every key as live | `…_warns_and_names_…` (a key-only principal reported reachable) |
//! | the `is_valid_at` check dropped | `an_expired_certificate_is_not_a_live_one` |
//! | the `preflight` call removed from `serve.rs` | the live refusal half only — which is why the live half exists |
use std::sync::Arc;
use gunnar_core::{Principal, PrincipalKind};
use gunnar_server::stores::ServerStores;
use gunnar_server::tls_preflight::{preflight, preflight_at, Verdict};
/// A store with an issuer and nothing else: the shape a fresh appliance has
/// before anybody has enrolled.
fn fresh() -> (tempfile::TempDir, Arc<ServerStores>) {
let dir = tempfile::tempdir().expect("a temp dir");
let stores = Arc::new(ServerStores::open(dir.path().join("data")).expect("open the stores"));
stores
.pki()
.expect("a pki")
.create("gunnar issuer", gunnar_core::DEFAULT_ISSUER_DAYS)
.expect("mint the issuer");
(dir, stores)
}
/// Add a principal holding one SSH key — someone who can reach the control
/// plane today and holds nothing a TLS handshake would accept.
fn with_ssh_key(stores: &ServerStores, id: &str) {
let private = gunnar_core::mint_ed25519().expect("mint a key");
let parsed = gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("parse");
let public = parsed.public_key().to_openssh().expect("render");
stores
.auth()
.add_principal(Principal::new(id, id, PrincipalKind::User).expect("a legal principal"))
.expect("add the principal");
stores
.auth()
.register_key(id, &public, None)
.expect("register the key");
}
/// Issue and register a certificate for a principal that already exists.
///
/// `expires` is gunnar's own expiry field on the registration, which is what
/// the preflight reads — see `tls_preflight`'s module docs on why it reads
/// that and not the certificate's own `notAfter`.
fn with_certificate(stores: &ServerStores, id: &str, expires: Option<i64>) {
let issued = stores
.pki()
.expect("a pki")
.issue_with_server_key(id, gunnar_core::DEFAULT_LEAF_DAYS)
.expect("issue a client certificate");
stores
.auth()
.register_certificate(id, &issued.cert_der, expires)
.expect("register the certificate");
}
// ── 1. the refusal ──────────────────────────────────────────────────────────
/// **A server that has never been enrolled must not come up mutual.**
///
/// This is the whole constraint the note names, turned from a sentence in a
/// doc comment into a refusal. The identity table is empty, so no certificate
/// this issuer signed has ever been registered, so every handshake fails —
/// including the one carrying `Bootstrap.Enroll`, which is mounted outside the
/// gate exactly because nobody holds a credential yet. The listener would
/// answer nobody and could never be enrolled over the network.
#[test]
fn the_preflight_refuses_a_server_that_has_never_been_enrolled() {
let (_dir, stores) = fresh();
let report = preflight(&stores).expect("read the identity table");
assert!(
report.bootstrap_window_open,
"a store with no principals, no keys and no bootstrap record must report the window \
open; if it does not, this test is asserting about the wrong state"
);
assert_eq!(report.verdict(), Verdict::Refuse);
let refusal = report
.refusal()
.expect("an unenrolled server must refuse --grpc-tls-self");
assert!(
refusal.contains("Bootstrap.Enroll"),
"the refusal must name the RPC that becomes unreachable, or an operator cannot tell \
this from a certificate problem; it said {refusal:?}"
);
assert!(
refusal.contains("gunnar control bootstrap"),
"…and the command that fixes it; it said {refusal:?}"
);
}
/// A server with no issuer cannot pin a CA or present a leaf, and the advance
/// check says so rather than letting `serve` be the one to discover it.
#[test]
fn the_preflight_refuses_a_server_with_no_issuer() {
let dir = tempfile::tempdir().expect("a temp dir");
let stores = Arc::new(ServerStores::open(dir.path().join("data")).expect("open the stores"));
// Enrolled, so the window is shut and the ONLY thing wrong is the issuer.
with_ssh_key(&stores, "alice");
let report = preflight(&stores).expect("read the identity table");
assert!(
!report.bootstrap_window_open,
"the window must be shut here, or this test would be passing for the other reason"
);
assert_eq!(report.issuer, None);
assert_eq!(report.verdict(), Verdict::Refuse);
assert!(
report
.refusal()
.expect("no issuer must refuse")
.contains("gunnar issuer create"),
"the refusal must name the verb that mints one"
);
}
// ── 2. the warning, with names ──────────────────────────────────────────────
/// **Who the flip costs, by name.**
///
/// One principal holds a certificate and one holds only an SSH key. The
/// verdict is `Warn` — it will start, and it will take something away — and
/// the locked-out list names the second and not the first.
///
/// Both halves matter. A preflight that named everybody would be useless in
/// the same way as one that named nobody, and only asserting the presence of
/// `keyonly` would pass against either.
#[test]
fn the_preflight_warns_and_names_a_principal_that_would_be_locked_out() {
let (_dir, stores) = fresh();
with_ssh_key(&stores, "certified");
with_certificate(&stores, "certified", None);
with_ssh_key(&stores, "keyonly");
let report = preflight(&stores).expect("read the identity table");
assert_eq!(report.verdict(), Verdict::Warn);
let locked: Vec<&str> = report.locked_out().map(|p| p.id.as_str()).collect();
assert_eq!(
locked,
vec!["keyonly"],
"exactly the principal with no certificate must be named; the report was {report:?}"
);
let reachable: Vec<&str> = report.reachable().map(|p| p.id.as_str()).collect();
assert_eq!(
reachable,
vec!["certified"],
"…and exactly the one with a certificate must be reachable"
);
// The rendered report is what an operator reads, so the name has to
// survive into it.
let rendered = report.to_string();
assert!(
rendered.contains("LOCKED") && rendered.contains("keyonly"),
"the printed report must mark and name the locked-out principal; it said:\n{rendered}"
);
}
/// **An expired certificate is not a live one.**
///
/// The handshake will refuse it; the identity table will happily list it. A
/// preflight that counted rows rather than live rows would call this server
/// ready and the operator would find out at the first reconnect.
#[test]
fn an_expired_certificate_is_not_a_live_one() {
let (_dir, stores) = fresh();
with_ssh_key(&stores, "alice");
// Registered as expiring at t=1000; the two readings below sit either
// side of that, so the SAME table gives two different verdicts and
// neither can be an accident of the fixture.
with_certificate(&stores, "alice", Some(1_000));
let before = preflight_at(&stores, 999).expect("read the identity table");
assert_eq!(
before.verdict(),
Verdict::Ready,
"while the certificate is live, alice can handshake"
);
assert_eq!(before.locked_out().count(), 0);
let after = preflight_at(&stores, 1_001).expect("read the identity table");
assert_eq!(
after.verdict(),
Verdict::Warn,
"once it has expired, alice cannot"
);
let locked: Vec<&str> = after.locked_out().map(|p| p.id.as_str()).collect();
assert_eq!(locked, vec!["alice"]);
assert_eq!(
after.principals[0].expired_certificates, 1,
"and the report must say WHY, or it reads as a principal that never had one"
);
assert_eq!(after.principals[0].live_certificates, 0);
}
// ── 3. ready ────────────────────────────────────────────────────────────────
/// Every principal that can authenticate today holds a live certificate, so
/// the flip takes nothing away.
#[test]
fn the_preflight_is_ready_when_every_principal_holds_a_live_certificate() {
let (_dir, stores) = fresh();
for id in ["alice", "bob"] {
with_ssh_key(&stores, id);
with_certificate(&stores, id, None);
}
let report = preflight(&stores).expect("read the identity table");
assert_eq!(report.verdict(), Verdict::Ready);
assert_eq!(report.locked_out().count(), 0);
assert_eq!(report.reachable().count(), 2);
assert!(
report.refusal().is_none(),
"a ready server must not also be refused"
);
assert!(
report.to_string().contains("READY."),
"the printed verdict is the line an operator reads"
);
}
// ── 4. the same check, on a live server ─────────────────────────────────────
/// **`gunnar serve --grpc-tls-self` refuses to start on an unenrolled server,
/// and starts on an enrolled one.**
///
/// The unit tests above prove the predicate; this proves it is *wired*. Both
/// directions in one test, over one code path, because the refusal alone
/// would be satisfied by a `serve` that refused `--grpc-tls-self` outright —
/// which is the change somebody would make if they read only the first half.
#[test]
fn serve_refuses_mutual_tls_before_enrolment_and_allows_it_after() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("a tokio runtime");
// ── unenrolled ───────────────────────────────────────────────────────
let (_empty_dir, empty) = fresh();
let err = runtime
.block_on(gunnar_server::Server::start(
Arc::clone(&empty),
gunnar_server::ServeConfig {
grpc_tls: Some(gunnar_server::GrpcTls::SelfMinted),
..gunnar_server::ServeConfig::ephemeral()
},
))
.err()
.expect(
"a server that has never been enrolled came up demanding client certificates; its \
control port would answer nobody and could never be enrolled over the network",
);
let message = format!("{err:#}");
assert!(
message.contains("NEVER BEEN ENROLLED") && message.contains("Bootstrap.Enroll"),
"the startup error must name the state and the RPC it breaks; it said {message:?}"
);
// ── enrolled, same flag ──────────────────────────────────────────────
let (_ready_dir, ready) = fresh();
with_ssh_key(&ready, "alice");
with_certificate(&ready, "alice", None);
let server = runtime
.block_on(gunnar_server::Server::start(
Arc::clone(&ready),
gunnar_server::ServeConfig {
grpc_tls: Some(gunnar_server::GrpcTls::SelfMinted),
..gunnar_server::ServeConfig::ephemeral()
},
))
.expect("an enrolled server must still be able to turn mutual TLS on");
assert!(
server.grpc_addr().is_some(),
"…and it must actually bind the control plane it just secured"
);
runtime
.block_on(server.shutdown())
.expect("a clean shutdown");
}