gunnar-server 2.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **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,
    })
}