Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-server/tests/tls_transition.rs

//! **Moving a control plane onto mutual TLS: the refusal, the warning, and the
//! one state where `gunnar serve` will not start at all.**
//!
//! The review's note says `--grpc-tls-self` cannot be the default because
//! bootstrap needs plaintext first. Everything the transition needs already
//! shipped — `Bootstrap.Enroll` hands out a client certificate, the CLI dials
//! `https://` when it finds one, `gunnar principal add --csr` issues the rest
//! — so what was missing was never the machinery. It was that **the flip had
//! no preflight**: nothing stopped an operator turning TLS on at the one
//! moment it produces a listener nobody can reach, and nothing said, before
//! the restart, who would stop being able to connect.
//!
//! `gunnar_server::tls_preflight` is that check, and this file drives it into
//! each of its three verdicts, then proves the refusal is real against a
//! **live `Server::start`** — both directions on the same code path, because a
//! test that only asserted the refusal would pass just as well against a
//! preflight that refused everything.
//!
//! # How each guard here was seen red
//!
//! | mutation | what went red |
//! |---|---|
//! | `refusal()` made to return `None` always | the two `Refuse` arms and the live `Server::start` refusal |
//! | `verdict()` made to return `Verdict::Refuse` always | `…_is_ready_when_…` and `…_warns_and_names_…`, and the live "starts once enrolled" half |
//! | `locked_out()` made `false` always | `…_warns_and_names_…` and `an_expired_certificate_is_not_a_live_one` |
//! | the `key.algo == CERTIFICATE_ALGO` arm made to count every key as live | `…_warns_and_names_…` (a key-only principal reported reachable) |
//! | the `is_valid_at` check dropped | `an_expired_certificate_is_not_a_live_one` |
//! | the `preflight` call removed from `serve.rs` | the live refusal half only — which is why the live half exists |

use std::sync::Arc;

use gunnar_core::{Principal, PrincipalKind};
use gunnar_server::stores::ServerStores;
use gunnar_server::tls_preflight::{preflight, preflight_at, Verdict};

/// A store with an issuer and nothing else: the shape a fresh appliance has
/// before anybody has enrolled.
fn fresh() -> (tempfile::TempDir, Arc<ServerStores>) {
    let dir = tempfile::tempdir().expect("a temp dir");
    let stores = Arc::new(ServerStores::open(dir.path().join("data")).expect("open the stores"));
    stores
        .pki()
        .expect("a pki")
        .create("gunnar issuer", gunnar_core::DEFAULT_ISSUER_DAYS)
        .expect("mint the issuer");
    (dir, stores)
}

/// Add a principal holding one SSH key — someone who can reach the control
/// plane today and holds nothing a TLS handshake would accept.
fn with_ssh_key(stores: &ServerStores, id: &str) {
    let private = gunnar_core::mint_ed25519().expect("mint a key");
    let parsed = gunnar_core::ssh_key::PrivateKey::from_openssh(&private).expect("parse");
    let public = parsed.public_key().to_openssh().expect("render");
    stores
        .auth()
        .add_principal(Principal::new(id, id, PrincipalKind::User).expect("a legal principal"))
        .expect("add the principal");
    stores
        .auth()
        .register_key(id, &public, None)
        .expect("register the key");
}

/// Issue and register a certificate for a principal that already exists.
///
/// `expires` is gunnar's own expiry field on the registration, which is what
/// the preflight reads — see `tls_preflight`'s module docs on why it reads
/// that and not the certificate's own `notAfter`.
fn with_certificate(stores: &ServerStores, id: &str, expires: Option<i64>) {
    let issued = stores
        .pki()
        .expect("a pki")
        .issue_with_server_key(id, gunnar_core::DEFAULT_LEAF_DAYS)
        .expect("issue a client certificate");
    stores
        .auth()
        .register_certificate(id, &issued.cert_der, expires)
        .expect("register the certificate");
}

// ── 1. the refusal ──────────────────────────────────────────────────────────

/// **A server that has never been enrolled must not come up mutual.**
///
/// This is the whole constraint the note names, turned from a sentence in a
/// doc comment into a refusal. The identity table is empty, so no certificate
/// this issuer signed has ever been registered, so every handshake fails —
/// including the one carrying `Bootstrap.Enroll`, which is mounted outside the
/// gate exactly because nobody holds a credential yet. The listener would
/// answer nobody and could never be enrolled over the network.
#[test]
fn the_preflight_refuses_a_server_that_has_never_been_enrolled() {
    let (_dir, stores) = fresh();
    let report = preflight(&stores).expect("read the identity table");

    assert!(
        report.bootstrap_window_open,
        "a store with no principals, no keys and no bootstrap record must report the window \
         open; if it does not, this test is asserting about the wrong state"
    );
    assert_eq!(report.verdict(), Verdict::Refuse);
    let refusal = report
        .refusal()
        .expect("an unenrolled server must refuse --grpc-tls-self");
    assert!(
        refusal.contains("Bootstrap.Enroll"),
        "the refusal must name the RPC that becomes unreachable, or an operator cannot tell \
         this from a certificate problem; it said {refusal:?}"
    );
    assert!(
        refusal.contains("gunnar control bootstrap"),
        "…and the command that fixes it; it said {refusal:?}"
    );
}

/// A server with no issuer cannot pin a CA or present a leaf, and the advance
/// check says so rather than letting `serve` be the one to discover it.
#[test]
fn the_preflight_refuses_a_server_with_no_issuer() {
    let dir = tempfile::tempdir().expect("a temp dir");
    let stores = Arc::new(ServerStores::open(dir.path().join("data")).expect("open the stores"));
    // Enrolled, so the window is shut and the ONLY thing wrong is the issuer.
    with_ssh_key(&stores, "alice");

    let report = preflight(&stores).expect("read the identity table");
    assert!(
        !report.bootstrap_window_open,
        "the window must be shut here, or this test would be passing for the other reason"
    );
    assert_eq!(report.issuer, None);
    assert_eq!(report.verdict(), Verdict::Refuse);
    assert!(
        report
            .refusal()
            .expect("no issuer must refuse")
            .contains("gunnar issuer create"),
        "the refusal must name the verb that mints one"
    );
}

// ── 2. the warning, with names ──────────────────────────────────────────────

/// **Who the flip costs, by name.**
///
/// One principal holds a certificate and one holds only an SSH key. The
/// verdict is `Warn` — it will start, and it will take something away — and
/// the locked-out list names the second and not the first.
///
/// Both halves matter. A preflight that named everybody would be useless in
/// the same way as one that named nobody, and only asserting the presence of
/// `keyonly` would pass against either.
#[test]
fn the_preflight_warns_and_names_a_principal_that_would_be_locked_out() {
    let (_dir, stores) = fresh();
    with_ssh_key(&stores, "certified");
    with_certificate(&stores, "certified", None);
    with_ssh_key(&stores, "keyonly");

    let report = preflight(&stores).expect("read the identity table");
    assert_eq!(report.verdict(), Verdict::Warn);

    let locked: Vec<&str> = report.locked_out().map(|p| p.id.as_str()).collect();
    assert_eq!(
        locked,
        vec!["keyonly"],
        "exactly the principal with no certificate must be named; the report was {report:?}"
    );
    let reachable: Vec<&str> = report.reachable().map(|p| p.id.as_str()).collect();
    assert_eq!(
        reachable,
        vec!["certified"],
        "…and exactly the one with a certificate must be reachable"
    );
    // The rendered report is what an operator reads, so the name has to
    // survive into it.
    let rendered = report.to_string();
    assert!(
        rendered.contains("LOCKED") && rendered.contains("keyonly"),
        "the printed report must mark and name the locked-out principal; it said:\n{rendered}"
    );
}

/// **An expired certificate is not a live one.**
///
/// The handshake will refuse it; the identity table will happily list it. A
/// preflight that counted rows rather than live rows would call this server
/// ready and the operator would find out at the first reconnect.
#[test]
fn an_expired_certificate_is_not_a_live_one() {
    let (_dir, stores) = fresh();
    with_ssh_key(&stores, "alice");
    // Registered as expiring at t=1000; the two readings below sit either
    // side of that, so the SAME table gives two different verdicts and
    // neither can be an accident of the fixture.
    with_certificate(&stores, "alice", Some(1_000));

    let before = preflight_at(&stores, 999).expect("read the identity table");
    assert_eq!(
        before.verdict(),
        Verdict::Ready,
        "while the certificate is live, alice can handshake"
    );
    assert_eq!(before.locked_out().count(), 0);

    let after = preflight_at(&stores, 1_001).expect("read the identity table");
    assert_eq!(
        after.verdict(),
        Verdict::Warn,
        "once it has expired, alice cannot"
    );
    let locked: Vec<&str> = after.locked_out().map(|p| p.id.as_str()).collect();
    assert_eq!(locked, vec!["alice"]);
    assert_eq!(
        after.principals[0].expired_certificates, 1,
        "and the report must say WHY, or it reads as a principal that never had one"
    );
    assert_eq!(after.principals[0].live_certificates, 0);
}

// ── 3. ready ────────────────────────────────────────────────────────────────

/// Every principal that can authenticate today holds a live certificate, so
/// the flip takes nothing away.
#[test]
fn the_preflight_is_ready_when_every_principal_holds_a_live_certificate() {
    let (_dir, stores) = fresh();
    for id in ["alice", "bob"] {
        with_ssh_key(&stores, id);
        with_certificate(&stores, id, None);
    }

    let report = preflight(&stores).expect("read the identity table");
    assert_eq!(report.verdict(), Verdict::Ready);
    assert_eq!(report.locked_out().count(), 0);
    assert_eq!(report.reachable().count(), 2);
    assert!(
        report.refusal().is_none(),
        "a ready server must not also be refused"
    );
    assert!(
        report.to_string().contains("READY."),
        "the printed verdict is the line an operator reads"
    );
}

// ── 4. the same check, on a live server ─────────────────────────────────────

/// **`gunnar serve --grpc-tls-self` refuses to start on an unenrolled server,
/// and starts on an enrolled one.**
///
/// The unit tests above prove the predicate; this proves it is *wired*. Both
/// directions in one test, over one code path, because the refusal alone
/// would be satisfied by a `serve` that refused `--grpc-tls-self` outright —
/// which is the change somebody would make if they read only the first half.
#[test]
fn serve_refuses_mutual_tls_before_enrolment_and_allows_it_after() {
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .expect("a tokio runtime");

    // ── unenrolled ───────────────────────────────────────────────────────
    let (_empty_dir, empty) = fresh();
    let err = runtime
        .block_on(gunnar_server::Server::start(
            Arc::clone(&empty),
            gunnar_server::ServeConfig {
                grpc_tls: Some(gunnar_server::GrpcTls::SelfMinted),
                ..gunnar_server::ServeConfig::ephemeral()
            },
        ))
        .err()
        .expect(
            "a server that has never been enrolled came up demanding client certificates; its \
             control port would answer nobody and could never be enrolled over the network",
        );
    let message = format!("{err:#}");
    assert!(
        message.contains("NEVER BEEN ENROLLED") && message.contains("Bootstrap.Enroll"),
        "the startup error must name the state and the RPC it breaks; it said {message:?}"
    );

    // ── enrolled, same flag ──────────────────────────────────────────────
    let (_ready_dir, ready) = fresh();
    with_ssh_key(&ready, "alice");
    with_certificate(&ready, "alice", None);
    let server = runtime
        .block_on(gunnar_server::Server::start(
            Arc::clone(&ready),
            gunnar_server::ServeConfig {
                grpc_tls: Some(gunnar_server::GrpcTls::SelfMinted),
                ..gunnar_server::ServeConfig::ephemeral()
            },
        ))
        .expect("an enrolled server must still be able to turn mutual TLS on");
    assert!(
        server.grpc_addr().is_some(),
        "…and it must actually bind the control plane it just secured"
    );
    runtime
        .block_on(server.shutdown())
        .expect("a clean shutdown");
}