Skip to main content

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}