crates/gunnar-server/src/tls_preflight.rs
//! **Moving a control plane onto mutual TLS, after it has been enrolled.**
//!
//! # The constraint, and why it is not the difficulty it looks like
//!
//! The review's note says it in one line: *`--grpc-tls-self` cannot be the
//! default, because bootstrap needs plaintext first*. A listener built with a
//! [`gunnar_grpc::Trust`] pins its own issuer as the CA every client
//! certificate must chain to, so a peer with no certificate never finishes a
//! handshake and never reaches a service — including [`Bootstrap.Enroll`],
//! which is mounted outside the gate precisely because nobody holds a
//! credential yet. On a server that has never been enrolled, nobody holds a
//! certificate either. TLS on + nothing enrolled is a **dead port**: the one
//! door that is open to a caller with no credential sits behind a handshake
//! that no caller can complete.
//!
//! What that argument does **not** say, and what it was read as saying, is
//! that TLS is unreachable. Everything the transition needs already shipped:
//!
//! * `Bootstrap.Enroll` hands the first admin a client certificate, its key
//! and the issuer PEM in the enrolment response
//! (`control_plane.rs::enroll_first_identity`), minted after the
//! compare-and-swap that shuts the window;
//! * `gunnar control bootstrap` writes all three to
//! `~/.config/gunnar/<server>/`, and the client dials `https://` whenever it
//! finds them (`control.rs::connect`);
//! * `gunnar principal add --csr` / `--server-generated-key` and
//! `gunnar key add --cert` issue and register a certificate for everybody
//! else, from the host;
//! * the listener mints its own leaf at every start and refuses to come up if
//! it cannot (`serve.rs`), and three red-first handshake tests pin the
//! refusals (`gunnar-cli/tests/mtls_handshake.rs`).
//!
//! So the ask is not "turn TLS on". **It is that the flip has no preflight**:
//! nothing stops an operator turning it on at the one moment it cannot work,
//! and nothing tells them, beforehand, who will stop being able to connect.
//! This module is that preflight, and it is one function with two readers —
//! the startup path in `serve.rs`, which refuses and warns, and
//! `gunnar issuer readiness`, which is the same question asked in advance.
//! One writer, both paths, so the answer an operator plans against is the
//! answer the server will act on.
//!
//! # The one refusal, and why it is the only one
//!
//! **The enrolment window is open.** That is exactly: no bootstrap record, no
//! principals, no keys ([`gunnar_core::AuthTable::bootstrap_window_open`]).
//! It is a refusal and not a warning because it is the only state in which the
//! resulting listener is provably unreachable by *anyone*: an empty identity
//! table means no certificate this issuer signed has ever been registered, so
//! every handshake fails, and the unauthenticated enrolment door that would
//! have fixed it is behind the same handshake. A server in that state answers
//! nothing until somebody with shell access on the box restarts it.
//!
//! Every other unhappy state is a **warning**, and the line between them is
//! whether the box can still be repaired from where it stands:
//!
//! * *nobody holds a certificate, but principals and keys exist* — the plane
//! is unreachable over the network, and an operator on the host fixes it
//! with `gunnar principal add --csr` or `gunnar key add --cert` **against
//! the running server**: the gate reads the identity table live, so a
//! certificate registered a minute from now is honoured on the next
//! handshake without a restart. Refusing to start would take away a
//! deliberate and recoverable configuration.
//! * *some principals hold a certificate and some do not* — the ones that do
//! not are named, because "who did I just lock out" is the question an
//! operator asks thirty seconds too late.
//! * *a principal's only certificate has expired* — counted as locked out and
//! named as expired, since the handshake will refuse it and the identity
//! table will not.
//!
//! # What this deliberately does not check
//!
//! Whether the certificates are any *good*: chain, validity dates against the
//! wall clock at connection time, revocation. That is the TLS handshake's job,
//! against the configured CA, and a second weaker opinion here would be the
//! one an attacker prefers gunnar to hold — the same argument
//! `AuthTable::register_certificate` already makes about not parsing what it
//! stores. The one date this module does read is `expires`, which is gunnar's
//! own field in its own table and is not a claim the certificate makes.
use anyhow::{Context, Result};
use crate::stores::ServerStores;
/// The `algo` [`gunnar_core::AuthTable::register_certificate`] records. A
/// registered SSH key never carries it, so this is what tells a certificate
/// row from a key row — the two share one fingerprint space on purpose, and
/// the digest alone cannot say which kind it is.
pub const CERTIFICATE_ALGO: &str = "x509";
/// One principal, and what it could present to a mutual listener.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrincipalTls {
pub id: String,
pub name: String,
/// Registered certificates that have not expired. Non-empty means this
/// principal can complete a handshake.
pub live_certificates: usize,
/// Registered certificates whose `expires` has passed.
pub expired_certificates: usize,
/// Registered SSH keys. A principal with keys and no live certificate is
/// one that can authenticate **today** and will not be able to after the
/// flip — which is what makes it worth naming.
pub ssh_keys: usize,
}
impl PrincipalTls {
/// Can this principal complete a handshake against a mutual listener?
pub fn can_handshake(&self) -> bool {
self.live_certificates > 0
}
/// Does the flip take something away from this principal?
///
/// Not the negation of [`Self::can_handshake`]: a principal with neither a
/// key nor a certificate — a row created for a grant that nobody has been
/// given credentials for yet — loses nothing, and listing it as locked out
/// would bury the ones that matter.
pub fn locked_out(&self) -> bool {
!self.can_handshake() && (self.ssh_keys > 0 || self.expired_certificates > 0)
}
}
/// What [`preflight`] found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlsPreflight {
/// `Bootstrap.Enroll` is still servable without a credential — the one
/// refusal.
pub bootstrap_window_open: bool,
/// The issuer's fingerprint, or `None` if this server has no issuer. A
/// server with no issuer cannot mint its listener's own leaf either, which
/// `serve.rs` already refuses for its own reason; it is reported here so
/// the advance check says the same thing the start-up will.
pub issuer: Option<String>,
/// Every principal, sorted by id.
pub principals: Vec<PrincipalTls>,
}
/// The three answers, in increasing order of "go ahead".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
/// Starting with `--grpc-tls-self` would produce a listener nobody can
/// reach and that cannot be enrolled. `serve.rs` refuses.
Refuse,
/// It will work, and it will take something away from somebody. The
/// somebodies are named.
Warn,
/// Every principal that can authenticate today holds a live certificate.
Ready,
}
impl TlsPreflight {
/// Principals that can authenticate today and will not be able to after
/// the flip.
pub fn locked_out(&self) -> impl Iterator<Item = &PrincipalTls> {
self.principals.iter().filter(|p| p.locked_out())
}
/// Principals that hold a live certificate.
pub fn reachable(&self) -> impl Iterator<Item = &PrincipalTls> {
self.principals.iter().filter(|p| p.can_handshake())
}
/// The verdict.
///
/// **It can answer all three**, and that is not decoration: a predicate
/// that could only ever say one thing would pass a test that asserted the
/// refusal and prove nothing about the other two. `tests/tls_transition.rs`
/// drives one table into each arm.
pub fn verdict(&self) -> Verdict {
if self.bootstrap_window_open || self.issuer.is_none() {
return Verdict::Refuse;
}
if self.locked_out().next().is_some() || self.reachable().next().is_none() {
return Verdict::Warn;
}
Verdict::Ready
}
/// The startup refusal, when there is one. `None` means it may start.
pub fn refusal(&self) -> Option<String> {
if self.bootstrap_window_open {
return Some(
"--grpc-tls-self was asked for on a server that has NEVER BEEN ENROLLED.\n\
\n\
A mutual listener refuses every peer that cannot present a certificate this \
server's issuer signed, and on an unenrolled server no such certificate \
exists: the identity table holds no principals, no keys and no bootstrap \
record. `Bootstrap.Enroll` — the one RPC served without a credential, and the \
one that would mint that first certificate — is behind the same handshake, so \
it could not be reached either. This listener would answer nobody, and \
nothing short of shell access on this host could change that.\n\
\n\
Enrol first, over plaintext, then restart with --grpc-tls-self:\n\
\n\
\x20 1. start `gunnar serve` WITHOUT --grpc-tls-self (loopback is the \
default);\n\
\x20 2. `gunnar control bootstrap --name <you> --key @~/.ssh/id_ed25519.pub` \
— the enrolment response carries your client certificate, its key and the \
issuer PEM, and `gunnar control bootstrap` writes all three;\n\
\x20 3. give every other principal a certificate: `gunnar principal add <id> \
--csr <req>` (their key never leaves their machine) or `gunnar key add <id> \
--cert <pem>` for one that already exists;\n\
\x20 4. `gunnar issuer readiness` — it says who would be locked out;\n\
\x20 5. restart with --grpc-tls-self."
.to_string(),
);
}
if self.issuer.is_none() {
return Some(
"--grpc-tls-self was asked for and this server has no certificate issuer, so \
there is no CA to pin and no leaf to present. `gunnar issuer create` mints \
one, `gunnar issuer import` adopts an existing one; a first start normally \
mints it and the warning that says why it did not is above this line."
.to_owned(),
);
}
None
}
/// Say out loud, at start-up, what the flip costs.
///
/// A `warn!` per locked-out principal and not one summary line: an
/// operator greps a journal for a name, and a count of four tells them
/// nothing they can act on.
pub fn warn(&self) {
if self.reachable().next().is_none() {
tracing::warn!(
principals = self.principals.len(),
"--grpc-tls-self is on and NOT ONE PRINCIPAL holds a live certificate, so no \
client can complete a handshake with this control plane. It is not sealed: \
the gate reads the identity table live, so `gunnar principal add <id> --csr \
<req>` or `gunnar key add <id> --cert <pem>` on this host takes effect on the \
next handshake with no restart."
);
}
for principal in self.locked_out() {
tracing::warn!(
principal = %principal.id,
ssh_keys = principal.ssh_keys,
expired_certificates = principal.expired_certificates,
"--grpc-tls-self is on and this principal holds no live certificate. It can \
authenticate to the control plane TODAY (and to git over SSH, which is a \
separate listener and is NOT affected) and will not complete a TLS handshake \
after this start. Issue it one with `gunnar principal add --csr` or register \
an existing one with `gunnar key add --cert`."
);
}
}
}
impl std::fmt::Display for TlsPreflight {
/// The report `gunnar issuer readiness` prints.
///
/// Written here rather than in `admin.rs` so the advance check and any
/// later reader render the same words; the verdict line is the last one
/// because it is the sentence the operator is reading for.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.issuer {
Some(fingerprint) => writeln!(f, "issuer {fingerprint}")?,
None => writeln!(f, "issuer NONE — nothing can be minted or pinned")?,
}
writeln!(
f,
"enrolment {}",
match self.bootstrap_window_open {
true => "OPEN — nothing has ever been enrolled",
false => "closed",
}
)?;
writeln!(f)?;
if self.principals.is_empty() {
writeln!(f, "no principals")?;
}
for principal in &self.principals {
let state = if principal.can_handshake() {
"ok "
} else if principal.locked_out() {
"LOCKED "
} else {
"— "
};
writeln!(
f,
"{state} {:<24} {} live cert, {} expired, {} ssh key",
principal.id,
principal.live_certificates,
principal.expired_certificates,
principal.ssh_keys
)?;
}
writeln!(f)?;
match self.verdict() {
Verdict::Refuse => {
// The same words `serve.rs` would refuse with, so an operator
// is never told two different stories about one server.
let refusal = self
.refusal()
.unwrap_or_else(|| "REFUSED, and the reason could not be rendered".to_owned());
write!(f, "NOT READY\n\n{refusal}")
}
Verdict::Warn => write!(
f,
"READY, WITH LOSSES. `gunnar serve --grpc-tls-self` will start, and every \
principal marked LOCKED above stops being able to reach the control plane. \
Git over SSH is a separate listener and is unaffected. Issue each of them a \
certificate first, or accept the loss knowingly — it is repairable from this \
host without a restart."
),
Verdict::Ready => write!(
f,
"READY. Every principal that can authenticate today holds a live certificate; \
`gunnar serve --grpc-tls-self` takes nothing away. Clients dial https:// with \
GUNNAR_CLIENT_CERT / GUNNAR_CLIENT_KEY / GUNNAR_CLIENT_CA."
),
}
}
}
/// **The one preflight.** Read the identity table and say whether this server
/// can be moved to mutual TLS.
///
/// A read fault propagates. It is never reported as "nothing is registered":
/// that would turn an unreadable identity table into a cheerful `Ready` and
/// then into a listener nobody can reach — the same reasoning
/// `AuthTable::prepare_to_serve` gives for never guessing an empty table.
pub fn preflight(stores: &ServerStores) -> Result<TlsPreflight> {
preflight_at(stores, gunnar_core::now_ms())
}
/// [`preflight`] with the clock supplied, so a test can place a certificate on
/// either side of its expiry without sleeping.
pub fn preflight_at(stores: &ServerStores, now_unix_ms: i64) -> Result<TlsPreflight> {
let auth = stores.auth();
let bootstrap_window_open = auth
.bootstrap_window_open()
.context("reading the enrolment window")?;
let principals = auth.principals().context("reading the principals")?;
let keys = auth.keys().context("reading the registered keys")?;
// Asked of the PKI the same way `serve.rs` asks it, and a missing secret
// store is "no issuer" rather than an error: a server built without one
// is a legitimate configuration that simply cannot do TLS, and the report
// should say so in the line about the issuer rather than fail to print.
let issuer = match stores.pki() {
Ok(pki) => pki
.issuer()
.context("reading the issuer")?
.map(|issuer| issuer.fingerprint.to_string()),
Err(_) => None,
};
let mut rows: Vec<PrincipalTls> = principals
.into_iter()
.map(|principal| PrincipalTls {
id: principal.id,
name: principal.name,
live_certificates: 0,
expired_certificates: 0,
ssh_keys: 0,
})
.collect();
rows.sort_by(|a, b| a.id.cmp(&b.id));
for key in keys {
let Ok(index) = rows.binary_search_by(|row| row.id.cmp(&key.principal_id)) else {
// A key whose principal is gone. `remove_principal` takes the keys
// with it, so this is a table that has been edited by something
// else; it cannot help anybody handshake and it is not counted
// against anybody.
continue;
};
let row = &mut rows[index];
if key.algo == CERTIFICATE_ALGO {
// `is_valid_at` is gunnar's own expiry field, not the
// certificate's `notAfter`: see the module docs on why this
// module reads one and not the other.
if key.is_valid_at(now_unix_ms) {
row.live_certificates += 1;
} else {
row.expired_certificates += 1;
}
} else if key.is_valid_at(now_unix_ms) {
row.ssh_keys += 1;
}
}
Ok(TlsPreflight {
bootstrap_window_open,
issuer,
principals: rows,
})
}