gunnar_server/tls_preflight.rs
1//! **Moving a control plane onto mutual TLS, after it has been enrolled.**
2//!
3//! # The constraint, and why it is not the difficulty it looks like
4//!
5//! The review's note says it in one line: *`--grpc-tls-self` cannot be the
6//! default, because bootstrap needs plaintext first*. A listener built with a
7//! [`gunnar_grpc::Trust`] pins its own issuer as the CA every client
8//! certificate must chain to, so a peer with no certificate never finishes a
9//! handshake and never reaches a service — including [`Bootstrap.Enroll`],
10//! which is mounted outside the gate precisely because nobody holds a
11//! credential yet. On a server that has never been enrolled, nobody holds a
12//! certificate either. TLS on + nothing enrolled is a **dead port**: the one
13//! door that is open to a caller with no credential sits behind a handshake
14//! that no caller can complete.
15//!
16//! What that argument does **not** say, and what it was read as saying, is
17//! that TLS is unreachable. Everything the transition needs already shipped:
18//!
19//! * `Bootstrap.Enroll` hands the first admin a client certificate, its key
20//! and the issuer PEM in the enrolment response
21//! (`control_plane.rs::enroll_first_identity`), minted after the
22//! compare-and-swap that shuts the window;
23//! * `gunnar control bootstrap` writes all three to
24//! `~/.config/gunnar/<server>/`, and the client dials `https://` whenever it
25//! finds them (`control.rs::connect`);
26//! * `gunnar principal add --csr` / `--server-generated-key` and
27//! `gunnar key add --cert` issue and register a certificate for everybody
28//! else, from the host;
29//! * the listener mints its own leaf at every start and refuses to come up if
30//! it cannot (`serve.rs`), and three red-first handshake tests pin the
31//! refusals (`gunnar-cli/tests/mtls_handshake.rs`).
32//!
33//! So the ask is not "turn TLS on". **It is that the flip has no preflight**:
34//! nothing stops an operator turning it on at the one moment it cannot work,
35//! and nothing tells them, beforehand, who will stop being able to connect.
36//! This module is that preflight, and it is one function with two readers —
37//! the startup path in `serve.rs`, which refuses and warns, and
38//! `gunnar issuer readiness`, which is the same question asked in advance.
39//! One writer, both paths, so the answer an operator plans against is the
40//! answer the server will act on.
41//!
42//! # The one refusal, and why it is the only one
43//!
44//! **The enrolment window is open.** That is exactly: no bootstrap record, no
45//! principals, no keys ([`gunnar_core::AuthTable::bootstrap_window_open`]).
46//! It is a refusal and not a warning because it is the only state in which the
47//! resulting listener is provably unreachable by *anyone*: an empty identity
48//! table means no certificate this issuer signed has ever been registered, so
49//! every handshake fails, and the unauthenticated enrolment door that would
50//! have fixed it is behind the same handshake. A server in that state answers
51//! nothing until somebody with shell access on the box restarts it.
52//!
53//! Every other unhappy state is a **warning**, and the line between them is
54//! whether the box can still be repaired from where it stands:
55//!
56//! * *nobody holds a certificate, but principals and keys exist* — the plane
57//! is unreachable over the network, and an operator on the host fixes it
58//! with `gunnar principal add --csr` or `gunnar key add --cert` **against
59//! the running server**: the gate reads the identity table live, so a
60//! certificate registered a minute from now is honoured on the next
61//! handshake without a restart. Refusing to start would take away a
62//! deliberate and recoverable configuration.
63//! * *some principals hold a certificate and some do not* — the ones that do
64//! not are named, because "who did I just lock out" is the question an
65//! operator asks thirty seconds too late.
66//! * *a principal's only certificate has expired* — counted as locked out and
67//! named as expired, since the handshake will refuse it and the identity
68//! table will not.
69//!
70//! # What this deliberately does not check
71//!
72//! Whether the certificates are any *good*: chain, validity dates against the
73//! wall clock at connection time, revocation. That is the TLS handshake's job,
74//! against the configured CA, and a second weaker opinion here would be the
75//! one an attacker prefers gunnar to hold — the same argument
76//! `AuthTable::register_certificate` already makes about not parsing what it
77//! stores. The one date this module does read is `expires`, which is gunnar's
78//! own field in its own table and is not a claim the certificate makes.
79
80use anyhow::{Context, Result};
81
82use crate::stores::ServerStores;
83
84/// The `algo` [`gunnar_core::AuthTable::register_certificate`] records. A
85/// registered SSH key never carries it, so this is what tells a certificate
86/// row from a key row — the two share one fingerprint space on purpose, and
87/// the digest alone cannot say which kind it is.
88pub const CERTIFICATE_ALGO: &str = "x509";
89
90/// One principal, and what it could present to a mutual listener.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct PrincipalTls {
93 pub id: String,
94 pub name: String,
95 /// Registered certificates that have not expired. Non-empty means this
96 /// principal can complete a handshake.
97 pub live_certificates: usize,
98 /// Registered certificates whose `expires` has passed.
99 pub expired_certificates: usize,
100 /// Registered SSH keys. A principal with keys and no live certificate is
101 /// one that can authenticate **today** and will not be able to after the
102 /// flip — which is what makes it worth naming.
103 pub ssh_keys: usize,
104}
105
106impl PrincipalTls {
107 /// Can this principal complete a handshake against a mutual listener?
108 pub fn can_handshake(&self) -> bool {
109 self.live_certificates > 0
110 }
111
112 /// Does the flip take something away from this principal?
113 ///
114 /// Not the negation of [`Self::can_handshake`]: a principal with neither a
115 /// key nor a certificate — a row created for a grant that nobody has been
116 /// given credentials for yet — loses nothing, and listing it as locked out
117 /// would bury the ones that matter.
118 pub fn locked_out(&self) -> bool {
119 !self.can_handshake() && (self.ssh_keys > 0 || self.expired_certificates > 0)
120 }
121}
122
123/// What [`preflight`] found.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct TlsPreflight {
126 /// `Bootstrap.Enroll` is still servable without a credential — the one
127 /// refusal.
128 pub bootstrap_window_open: bool,
129 /// The issuer's fingerprint, or `None` if this server has no issuer. A
130 /// server with no issuer cannot mint its listener's own leaf either, which
131 /// `serve.rs` already refuses for its own reason; it is reported here so
132 /// the advance check says the same thing the start-up will.
133 pub issuer: Option<String>,
134 /// Every principal, sorted by id.
135 pub principals: Vec<PrincipalTls>,
136}
137
138/// The three answers, in increasing order of "go ahead".
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum Verdict {
141 /// Starting with `--grpc-tls-self` would produce a listener nobody can
142 /// reach and that cannot be enrolled. `serve.rs` refuses.
143 Refuse,
144 /// It will work, and it will take something away from somebody. The
145 /// somebodies are named.
146 Warn,
147 /// Every principal that can authenticate today holds a live certificate.
148 Ready,
149}
150
151impl TlsPreflight {
152 /// Principals that can authenticate today and will not be able to after
153 /// the flip.
154 pub fn locked_out(&self) -> impl Iterator<Item = &PrincipalTls> {
155 self.principals.iter().filter(|p| p.locked_out())
156 }
157
158 /// Principals that hold a live certificate.
159 pub fn reachable(&self) -> impl Iterator<Item = &PrincipalTls> {
160 self.principals.iter().filter(|p| p.can_handshake())
161 }
162
163 /// The verdict.
164 ///
165 /// **It can answer all three**, and that is not decoration: a predicate
166 /// that could only ever say one thing would pass a test that asserted the
167 /// refusal and prove nothing about the other two. `tests/tls_transition.rs`
168 /// drives one table into each arm.
169 pub fn verdict(&self) -> Verdict {
170 if self.bootstrap_window_open || self.issuer.is_none() {
171 return Verdict::Refuse;
172 }
173 if self.locked_out().next().is_some() || self.reachable().next().is_none() {
174 return Verdict::Warn;
175 }
176 Verdict::Ready
177 }
178
179 /// The startup refusal, when there is one. `None` means it may start.
180 pub fn refusal(&self) -> Option<String> {
181 if self.bootstrap_window_open {
182 return Some(
183 "--grpc-tls-self was asked for on a server that has NEVER BEEN ENROLLED.\n\
184 \n\
185 A mutual listener refuses every peer that cannot present a certificate this \
186 server's issuer signed, and on an unenrolled server no such certificate \
187 exists: the identity table holds no principals, no keys and no bootstrap \
188 record. `Bootstrap.Enroll` — the one RPC served without a credential, and the \
189 one that would mint that first certificate — is behind the same handshake, so \
190 it could not be reached either. This listener would answer nobody, and \
191 nothing short of shell access on this host could change that.\n\
192 \n\
193 Enrol first, over plaintext, then restart with --grpc-tls-self:\n\
194 \n\
195 \x20 1. start `gunnar serve` WITHOUT --grpc-tls-self (loopback is the \
196 default);\n\
197 \x20 2. `gunnar control bootstrap --name <you> --key @~/.ssh/id_ed25519.pub` \
198 — the enrolment response carries your client certificate, its key and the \
199 issuer PEM, and `gunnar control bootstrap` writes all three;\n\
200 \x20 3. give every other principal a certificate: `gunnar principal add <id> \
201 --csr <req>` (their key never leaves their machine) or `gunnar key add <id> \
202 --cert <pem>` for one that already exists;\n\
203 \x20 4. `gunnar issuer readiness` — it says who would be locked out;\n\
204 \x20 5. restart with --grpc-tls-self."
205 .to_string(),
206 );
207 }
208 if self.issuer.is_none() {
209 return Some(
210 "--grpc-tls-self was asked for and this server has no certificate issuer, so \
211 there is no CA to pin and no leaf to present. `gunnar issuer create` mints \
212 one, `gunnar issuer import` adopts an existing one; a first start normally \
213 mints it and the warning that says why it did not is above this line."
214 .to_owned(),
215 );
216 }
217 None
218 }
219
220 /// Say out loud, at start-up, what the flip costs.
221 ///
222 /// A `warn!` per locked-out principal and not one summary line: an
223 /// operator greps a journal for a name, and a count of four tells them
224 /// nothing they can act on.
225 pub fn warn(&self) {
226 if self.reachable().next().is_none() {
227 tracing::warn!(
228 principals = self.principals.len(),
229 "--grpc-tls-self is on and NOT ONE PRINCIPAL holds a live certificate, so no \
230 client can complete a handshake with this control plane. It is not sealed: \
231 the gate reads the identity table live, so `gunnar principal add <id> --csr \
232 <req>` or `gunnar key add <id> --cert <pem>` on this host takes effect on the \
233 next handshake with no restart."
234 );
235 }
236 for principal in self.locked_out() {
237 tracing::warn!(
238 principal = %principal.id,
239 ssh_keys = principal.ssh_keys,
240 expired_certificates = principal.expired_certificates,
241 "--grpc-tls-self is on and this principal holds no live certificate. It can \
242 authenticate to the control plane TODAY (and to git over SSH, which is a \
243 separate listener and is NOT affected) and will not complete a TLS handshake \
244 after this start. Issue it one with `gunnar principal add --csr` or register \
245 an existing one with `gunnar key add --cert`."
246 );
247 }
248 }
249}
250
251impl std::fmt::Display for TlsPreflight {
252 /// The report `gunnar issuer readiness` prints.
253 ///
254 /// Written here rather than in `admin.rs` so the advance check and any
255 /// later reader render the same words; the verdict line is the last one
256 /// because it is the sentence the operator is reading for.
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 match &self.issuer {
259 Some(fingerprint) => writeln!(f, "issuer {fingerprint}")?,
260 None => writeln!(f, "issuer NONE — nothing can be minted or pinned")?,
261 }
262 writeln!(
263 f,
264 "enrolment {}",
265 match self.bootstrap_window_open {
266 true => "OPEN — nothing has ever been enrolled",
267 false => "closed",
268 }
269 )?;
270 writeln!(f)?;
271 if self.principals.is_empty() {
272 writeln!(f, "no principals")?;
273 }
274 for principal in &self.principals {
275 let state = if principal.can_handshake() {
276 "ok "
277 } else if principal.locked_out() {
278 "LOCKED "
279 } else {
280 "— "
281 };
282 writeln!(
283 f,
284 "{state} {:<24} {} live cert, {} expired, {} ssh key",
285 principal.id,
286 principal.live_certificates,
287 principal.expired_certificates,
288 principal.ssh_keys
289 )?;
290 }
291 writeln!(f)?;
292 match self.verdict() {
293 Verdict::Refuse => {
294 // The same words `serve.rs` would refuse with, so an operator
295 // is never told two different stories about one server.
296 let refusal = self
297 .refusal()
298 .unwrap_or_else(|| "REFUSED, and the reason could not be rendered".to_owned());
299 write!(f, "NOT READY\n\n{refusal}")
300 }
301 Verdict::Warn => write!(
302 f,
303 "READY, WITH LOSSES. `gunnar serve --grpc-tls-self` will start, and every \
304 principal marked LOCKED above stops being able to reach the control plane. \
305 Git over SSH is a separate listener and is unaffected. Issue each of them a \
306 certificate first, or accept the loss knowingly — it is repairable from this \
307 host without a restart."
308 ),
309 Verdict::Ready => write!(
310 f,
311 "READY. Every principal that can authenticate today holds a live certificate; \
312 `gunnar serve --grpc-tls-self` takes nothing away. Clients dial https:// with \
313 GUNNAR_CLIENT_CERT / GUNNAR_CLIENT_KEY / GUNNAR_CLIENT_CA."
314 ),
315 }
316 }
317}
318
319/// **The one preflight.** Read the identity table and say whether this server
320/// can be moved to mutual TLS.
321///
322/// A read fault propagates. It is never reported as "nothing is registered":
323/// that would turn an unreadable identity table into a cheerful `Ready` and
324/// then into a listener nobody can reach — the same reasoning
325/// `AuthTable::prepare_to_serve` gives for never guessing an empty table.
326pub fn preflight(stores: &ServerStores) -> Result<TlsPreflight> {
327 preflight_at(stores, gunnar_core::now_ms())
328}
329
330/// [`preflight`] with the clock supplied, so a test can place a certificate on
331/// either side of its expiry without sleeping.
332pub fn preflight_at(stores: &ServerStores, now_unix_ms: i64) -> Result<TlsPreflight> {
333 let auth = stores.auth();
334 let bootstrap_window_open = auth
335 .bootstrap_window_open()
336 .context("reading the enrolment window")?;
337 let principals = auth.principals().context("reading the principals")?;
338 let keys = auth.keys().context("reading the registered keys")?;
339 // Asked of the PKI the same way `serve.rs` asks it, and a missing secret
340 // store is "no issuer" rather than an error: a server built without one
341 // is a legitimate configuration that simply cannot do TLS, and the report
342 // should say so in the line about the issuer rather than fail to print.
343 let issuer = match stores.pki() {
344 Ok(pki) => pki
345 .issuer()
346 .context("reading the issuer")?
347 .map(|issuer| issuer.fingerprint.to_string()),
348 Err(_) => None,
349 };
350
351 let mut rows: Vec<PrincipalTls> = principals
352 .into_iter()
353 .map(|principal| PrincipalTls {
354 id: principal.id,
355 name: principal.name,
356 live_certificates: 0,
357 expired_certificates: 0,
358 ssh_keys: 0,
359 })
360 .collect();
361 rows.sort_by(|a, b| a.id.cmp(&b.id));
362
363 for key in keys {
364 let Ok(index) = rows.binary_search_by(|row| row.id.cmp(&key.principal_id)) else {
365 // A key whose principal is gone. `remove_principal` takes the keys
366 // with it, so this is a table that has been edited by something
367 // else; it cannot help anybody handshake and it is not counted
368 // against anybody.
369 continue;
370 };
371 let row = &mut rows[index];
372 if key.algo == CERTIFICATE_ALGO {
373 // `is_valid_at` is gunnar's own expiry field, not the
374 // certificate's `notAfter`: see the module docs on why this
375 // module reads one and not the other.
376 if key.is_valid_at(now_unix_ms) {
377 row.live_certificates += 1;
378 } else {
379 row.expired_certificates += 1;
380 }
381 } else if key.is_valid_at(now_unix_ms) {
382 row.ssh_keys += 1;
383 }
384 }
385
386 Ok(TlsPreflight {
387 bootstrap_window_open,
388 issuer,
389 principals: rows,
390 })
391}