use anyhow::{Context, Result};
use crate::stores::ServerStores;
pub const CERTIFICATE_ALGO: &str = "x509";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrincipalTls {
pub id: String,
pub name: String,
pub live_certificates: usize,
pub expired_certificates: usize,
pub ssh_keys: usize,
}
impl PrincipalTls {
pub fn can_handshake(&self) -> bool {
self.live_certificates > 0
}
pub fn locked_out(&self) -> bool {
!self.can_handshake() && (self.ssh_keys > 0 || self.expired_certificates > 0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlsPreflight {
pub bootstrap_window_open: bool,
pub issuer: Option<String>,
pub principals: Vec<PrincipalTls>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Refuse,
Warn,
Ready,
}
impl TlsPreflight {
pub fn locked_out(&self) -> impl Iterator<Item = &PrincipalTls> {
self.principals.iter().filter(|p| p.locked_out())
}
pub fn reachable(&self) -> impl Iterator<Item = &PrincipalTls> {
self.principals.iter().filter(|p| p.can_handshake())
}
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
}
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
}
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 {
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 => {
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: 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,
})
}