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

nordisk/gunnar

crates/gunnar-grpc/src/lib.rs

//! # gunnar-grpc: the gRPC control plane
//!
//! Tonic implementations of the seven services in [`gunnar_proto`], plus the
//! event bus they publish to.
//!
//! ---
//!
//! # Authentication: the allow-all hole is CLOSED
//!
//! **Every RPC in this crate requires a verified caller and a grant.** The v1
//! allow-all hole ("the highest-priority follow-up in the
//! whole design") is gone. What replaced it:
//!
//! - **Identity is SSH-key-derived**, from the *same* ACL key table the SSH
//!   listener authenticates git push and fetch against. A caller signs a
//!   single-use challenge with a registered key; [`auth`] verifies the
//!   `sshsig` and names the principal. There is no second key store, no
//!   certificate authority and no shared secret.
//! - **Authorisation is `gunnar_core::AuthTable::may`**, reached through
//!   [`seam::Identity::may`]. This crate does not compute a grant decision; it
//!   asks for one. Two ACLs that can disagree is what the one-sandbox rule had to undo once
//!   already (one implementation).
//! - **Deny by default, twice.** [`auth::AuthGate`] refuses a request with no
//!   valid credential before any handler runs, and every handler independently
//!   refuses a request that arrives without a [`auth::Caller`], so mounting
//!   these services without the gate fails closed rather than open.
//! - **Every authorised call is audited.** One record per RPC on the
//!   [`auth::AUDIT_TARGET`] tracing target, naming the method, the principal,
//!   the key fingerprint, the scope, the level and the decision. Before this,
//!   every gRPC service file had **zero** log lines: an unauthenticated
//!   mutating control plane with no trail.
//!
//! ## The credential
//!
//! ```text
//! gunnar-authorization: gunnar-sshsig-v1 <SHA256:…> <issued_at_ms> <nonce> <sshsig>
//! ```
//!
//! [`auth::sign_credential`] builds one; see the [`auth`] module docs for the
//! challenge bytes, the freshness window, the single-use nonce and why mTLS and
//! bearer tokens were rejected.
//!
//! ## The development escape hatch
//!
//! [`ServerConfig::insecure_allow_all_for_local_development`] restores the old
//! allow-all behaviour for one listener. It is **never** the default, cannot be
//! reached from an environment variable or a config file, warns when it is
//! constructed, warns again at every server start, is reported as
//! `grpc_authentication_enabled: false` by `Admin.Health`, and stamps
//! `auth_mode="allow-all-insecure"` on every audit record it produces.
//!
//! ## What gRPC still does not touch
//!
//! **Git push and fetch are unaffected**, as they always were. They run on a
//! **separate SSH listener** with publickey authentication against
//! the same key table, on a different port, in a different crate
//! (`gunnar-ssh`). There is no SSH-over-gRPC bridge and none is wanted, and no
//! RPC anywhere in the surface moves a ref: refs move through
//! `git-receive-pack` behind the policy gate (the pre-receive gate).
//!
//! ## The loopback default stays
//!
//! [`config::DEFAULT_BIND`] is still `127.0.0.1:50051`, and
//! `tests/loopback_default.rs` still goes red if anyone widens it. Closing the
//! authentication hole is not a licence to bind `0.0.0.0`: defence in depth
//! costs nothing here, and every real deployment reaches gRPC over an SSH
//! tunnel or from a process on the same host anyway.
//!
//! ---
//!
//! # The event bus
//!
//! [`bus::EventBus`] is a `tokio::sync::broadcast` ring. `receive-pack` will
//! publish `RefUpdated` / `PushRejected` / `PushAttestation` to it; the
//! services here publish `RepoCreated` / `RepoDeleted` / `MirrorSynced`, now
//! carrying the principal that caused them. A subscriber that falls behind is
//! handed an [`gunnar_proto::EventGap`] naming how many events it lost and
//! where the stream resumes. **Never an unbounded queue.** See [`bus`] for why
//! that is the only acceptable trade on a server that may be PID 1 in a
//! distroless image.
//!
//! # The storage seam
//!
//! Services are written against [`seam::ControlPlane`], a small local trait,
//! because `gunnar-store` and `gunnar-core` are being written in parallel.
//! **That trait is scaffolding with a documented replacement plan.** See the
//! [`seam`] module docs before building anything on top of it.
#![doc(
    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]

pub mod attest;
pub mod auth;
pub mod bus;
pub mod client_signer;

/// The group whose members read a primary's transaction log without being its
/// administrators: the twins (`.nornir/twin-transaction-log.md`).
pub const TWIN_GROUP: &str = "twin";

/// **The estate's REPAIR principal** (main rebuild, owner 2026-10-12: a narrow key, never
/// admin). The installer feeds it (`principal.repair = "machine repair <key>"`); main and
/// twin sign their repair calls with its private half. Whatever grants it holds, it acts ONLY
/// through [`REPAIR_METHODS`] — on every box, a twin included, with no file to write (the
/// narrowing is the name's): it reads a twin's log, fences an old primary to STANDBY, and
/// asks who it is. A fresh main takes its `Bootstrap.Restore` (a fed restore key).
pub const REPAIR_PRINCIPAL: &str = "repair";

/// What [`REPAIR_PRINCIPAL`] may call: the restore-from-twin path, its fence and its look.
pub const REPAIR_METHODS: &[&str] = &[
    "Mirror.LogHead",
    "Mirror.LogFetch",
    "Mirror.SnapshotFetch",
    "Mirror.SetRole",
    "Mirror.DemoteReplication",
    "Access.WhoAmI",
];
/// **The refusal a primary answers while its data set is grown** (`Mirror.LogFetch` with the
/// transaction log released for the remount): `Unavailable`, carrying this tag. A caller that
/// reads it WAITS — it is the box doing what it was told, not a fault (M1/M2, release-3 test2
/// mock 2026-09-29).
pub const PRIMARY_GROWING: &str = "[primary-growing]";
/// **The refusal a twin gets when main's data set grew while it was silent and its own
/// volume cannot hold it** (cov-13, 2026-10-06; [`twin_owed`]): `FailedPrecondition` on
/// `Mirror.LogFetch` / `Mirror.SnapshotFetch`, carrying this tag. The twin waits in
/// `recovering`, polling — it is owed a growth, not broken.
pub const TWIN_TOO_SMALL: &str = "[twin-too-small]";
/// Clans at the seam (2026-10-05): ordinary accounts grouped, each giving part of its room.
pub mod clans;
pub mod config;
/// The owner's enterprise list at the seam: the account audit log, the operator's look, the name filter.
pub mod enterprise;
/// The data set's grow journal, read for `Admin.Health` and `Admin.Capacity`.
pub mod grow_journal;
pub mod heartbeat;
pub mod identity;
pub mod machine_enrolment;
pub mod offload;
/// The transaction log's default retention behind the slowest reporting twin (h-txlog-2x).
pub mod quiet;
/// A repository's settings asked over another door (the forge's SSH door), answered by `Repos` itself (2026-10-14).
pub mod repo_door;
pub mod retention;
pub mod seam;
pub mod security_key;
/// Whether this estate SELLS room: a person's account with no fact holds none there (2026-10-08).
pub mod selling;
pub mod service;
/// Company single sign-on (OIDC, SAML 2.0), SCIM 2.0 and the CSV import.
pub mod sso;
mod status;
/// `Admin.Topology`: the estate's boxes as this gunnar knows them (2026-10-04).
pub mod topology;
pub mod trace;
pub mod twin_owed;
/// **WebAuthn, decided by gunnar** (Stage L): passkey keys, assertions and the
/// challenge store, from the shared edda crate (A34 M-3, 2026-10-27: Gunnar's own
/// `webauthn.rs` moved there, with the per-client challenge partition).
pub use nornir_auth_webauthn::ceremony as webauthn;

use std::sync::Arc;

pub use attest::attestation_json_line;

/// **What monetize's gunnar plugin calls** (f-principal-reach): the methods a
/// principal narrowed to billing may call and nothing else — a CENTRAL monetize
/// serving an attached estate is never a server-wide admin there. Read off the
/// plugin's own calls (monetize products/gunnar, gunnar-monetize-guest): the
/// entitlement it signs, the capacity and growth it orders, the usage it
/// reads, the purge of a lapsed tenant — and, since "Add users" (2026-10-04),
/// the employees and seats an order bought room for: monetize asks whether
/// they fit (a dry run) before money moves and grants them when it is paid.
pub const BILLING_METHODS: &[&str] = &[
    "Entitlement.Set",
    "Entitlement.List",
    "Admin.Capacity",
    "Admin.Health",
    "Admin.GrowStatus",
    "Admin.GrowStart",
    "Admin.GrowResume",
    "Usage.ReadAll",
    // The same read, a page at a time and only what changed (2026-10-06).
    "UsageBatch.ReadMany",
    "Accounts.Purge",
    "Accounts.EnrollEmployee",
];
pub use auth::{
    method_name_from_path, sign_credential, sign_credential_for, Accepted, Access, AuthFailure,
    AuthGate, AuthMode, CalledMethod, Caller, Credential, ReplayGuard, SignError,
    UnboundCredentialLog, UnboundCredentialReport, UnboundSighting, ANONYMOUS_PRINCIPAL_ID,
    AUDIT_TARGET, CREDENTIAL_METADATA_KEY, CREDENTIAL_SCHEME, CREDENTIAL_SCHEME_V1,
    DEFAULT_UNBOUND_LOG_CAPACITY, KEY_ENROLLING, UNAUTHENTICATED_METHODS, UNIMPLEMENTED_METHODS,
    UNKNOWN_METHOD,
};
pub use bus::{BusStats, EventBus, EventFilter, EventStream};
pub use config::{ServerConfig, AUTHENTICATION_ENABLED, DEFAULT_BIND, DEFAULT_EVENT_BUFFER};
pub use seam::{
    BootstrapInvite, BootstrapState, CertificateIdentity, ControlError, ControlPlane,
    ControlResult, EmployeeAnswer, EmployeeAsk, Enrolled, FirstBoot, FirstInvite, Identity,
    MachineApproval, MachineApproved, MemoryControlPlane, MergeOptions, MergedRef, NewRepo,
    OperatorClaim, PrincipalSeen, RegisteredKey, RestoreIntake, StoredCredential, TransferGate,
    TransferHold, TransferPermit, TransferRefused, CONTENT_ALREADY_ON_TARGET,
};
/// Clans (2026-10-05).
pub use service::ClansService;
pub use service::{
    AccessService, AccountsService, AdminService, BootstrapService, EnterpriseService,
    EventsService, ForgeService, Listing, MirrorService, PolicyService, PullsService, RefsService,
    ReposService, ServiceContext, SsoService, UsageBatchService, UsageService, WebhooksService,
    BROWSE_STILL_ANSWERS,
};
#[cfg(feature = "metered")]
pub use service::{BillingService, EntitlementService, SealedService};
pub use trace::{
    render_request_id, RequestIdSource, RequestSpan, RequestSpanLayer, REQUEST_ID_METADATA_KEY,
};

use gunnar_proto::access_server::AccessServer;
use gunnar_proto::accounts_server::AccountsServer;
use gunnar_proto::admin_server::AdminServer;
#[cfg(feature = "metered")]
use gunnar_proto::billing_server::BillingServer;
use gunnar_proto::bootstrap_server::BootstrapServer;
use gunnar_proto::clans_server::ClansServer;
use gunnar_proto::enterprise_server::EnterpriseServer;
#[cfg(feature = "metered")]
use gunnar_proto::entitlement_server::EntitlementServer;
use gunnar_proto::events_server::EventsServer;
use gunnar_proto::forge_server::ForgeServer;
use gunnar_proto::mirror_server::MirrorServer;
use gunnar_proto::policy_server::PolicyServer;
use gunnar_proto::pull_requests_server::PullRequestsServer;
use gunnar_proto::refs_server::RefsServer;
use gunnar_proto::repos_server::ReposServer;
#[cfg(feature = "metered")]
use gunnar_proto::sealed_server::SealedServer;
use gunnar_proto::sso_server::SsoServer;
use gunnar_proto::usage_batch_server::UsageBatchServer;
use gunnar_proto::usage_server::UsageServer;
use gunnar_proto::webhooks_server::WebhooksServer;

/// Say, at every start, whether this listener authenticates.
///
/// Silence is what turns a knowing hole into a forgotten one, so the escape
/// hatch is announced on every start and not only where it is constructed.
/// With authentication on it is one `info!` line, which is also worth having:
/// an operator reading a log should be able to tell which of the two it is
/// without going to look at the config.
pub fn warn_authentication_disabled(config: &ServerConfig) {
    if config.authentication_enabled() {
        tracing::info!(
            bind = %config.bind,
            loopback = config.is_loopback_bind(),
            "gRPC authentication is ON: every control-plane call needs an sshsig credential \
             from a key in gunnar's ACL key table, and a grant from the same ACL the SSH \
             listener uses."
        );
        return;
    }
    tracing::warn!(
        bind = %config.bind,
        loopback = config.is_loopback_bind(),
        "gRPC AUTHENTICATION IS OFF for this listener (the local-development escape hatch). \
         Every control-plane mutation is served to any caller that can reach this port: \
         principals, keys, grants, branch protection, repo create/delete. Git push/fetch are \
         NOT affected: they run on the separate SSH listener with publickey auth, so an open \
         gRPC port cannot push code. Do not deploy this."
    );
    if !config.is_loopback_bind() {
        tracing::error!(
            bind = %config.bind,
            "…and this listener is NOT on loopback. The unauthenticated control plane is \
             reachable from the network."
        );
    }
}

/// What [`router`] returns: tonic's router with gunnar's one layer under it.
///
/// Spelled out as an alias because the type is the *proof* that the layer is
/// mounted — a caller cannot obtain a gunnar gRPC router without it, and a
/// future edit that drops the layer changes this signature rather than quietly
/// losing every request id.
///
/// Two layers since R5.1-G: the span outside, then [`offload::OffloadLayer`],
/// which runs each call on the context's handler runtime so slow synchronous
/// calls can never hold the workers that answer the connection's keepalive.
pub type GunnarRouter = tonic::transport::server::Router<
    tower_layer::Stack<
        offload::OffloadLayer,
        tower_layer::Stack<RequestSpanLayer, tower_layer::Identity>,
    >,
>;

/// Assemble every service onto a tonic router, behind the authentication gate
/// and under the request-id span.
///
/// Split out from [`serve`] so a caller that wants to add its own services, or
/// its own layers, does not have to reimplement the wiring, and so that
/// *every* such caller gets the gate. It is applied here, once, to all seven
/// services rather than seven times at seven call sites (one implementation).
///
/// **One [`ReplayGuard`] is shared by all seven.** Seven guards would let one
/// credential be spent once per service, which is six replays.
///
/// # `request_ids`
///
/// A [`RequestIdSource`], and a **required** argument rather than a defaulted
/// one. The id has to come from the same counter the SSH and HTTP listeners mint
/// from, this crate cannot link the crate that owns that counter, and a default
/// here would be a second sequence that nobody decided to create. See
/// [`trace`].
/// **gunnar's trust root**: the CA that decides which clients exist.
///
/// One CA, named `trust`. A server configured with it accepts a client whose
/// certificate that CA signed and refuses every other — including one signed by
/// a perfectly valid CA that simply is not this one. That refusal is the point;
/// a trust root that accepts anything well-formed is decoration (the mTLS plan).
///
/// Presenting a certificate is **not** authorisation. It gets the connection as
/// far as a [`Fingerprint`](gunnar_core::Fingerprint), which the `AuthTable`
/// maps to a principal, which the grants decide for. A certificate signed by the
/// CA but never registered reaches the gate and is refused there, with the same
/// audit record any unknown credential produces.
#[derive(Debug, Clone)]
pub struct Trust {
    /// PEM of the CA that must have signed every client certificate.
    pub client_ca: Vec<u8>,
    /// PEM of the certificate this server presents.
    pub cert: Vec<u8>,
    /// PEM of its private key.
    pub key: Vec<u8>,
}

impl Trust {
    /// Read the three from files.
    pub fn from_files(
        client_ca: &std::path::Path,
        cert: &std::path::Path,
        key: &std::path::Path,
    ) -> std::io::Result<Self> {
        Ok(Trust {
            client_ca: std::fs::read(client_ca)?,
            cert: std::fs::read(cert)?,
            key: std::fs::read(key)?,
        })
    }

    /// The tonic configuration this describes.
    ///
    /// `client_ca_root` is what makes it **mutual**: without it a client needs
    /// no certificate at all, and the listener would be ordinary TLS wearing
    /// mTLS's name — which is exactly the failure that shows up as a green test
    /// suite and an open door.
    pub fn server_tls(&self) -> tonic::transport::ServerTlsConfig {
        tonic::transport::ServerTlsConfig::new()
            .identity(tonic::transport::Identity::from_pem(&self.cert, &self.key))
            .client_ca_root(tonic::transport::Certificate::from_pem(&self.client_ca))
    }
}

/// The DER of the certificate this request's peer presented.
///
/// `None` when the connection carried no client certificate, which on a
/// [`Trust`]-configured listener cannot happen — the handshake would have failed
/// first — and which on a plaintext listener always does. Both are the same
/// answer to the caller: **no identity**, refuse.
///
/// # Why this returns an `Option` and not an error
///
/// The absence of a certificate is not a failure here, it is a fact about the
/// connection. Making it an error would put a second refusal path beside the
/// gate's, and two places that can refuse a request are two places that can
/// disagree about why — which is what makes an audit trail unreadable.
///
/// # Why this hands back DER rather than a `Fingerprint`
///
/// This crate does not depend on `gunnar-core` and should not grow that edge to
/// call one hash function. The caller has it — `gunnar_core::auth::
/// fingerprint_of_certificate` — and hashing at that end also keeps a single
/// place deciding what a credential's bytes are, which is the property PEM
/// unwrapping already had to preserve.
///
/// # Why the whole certificate and not the subject
///
/// A subject is a claim the certificate makes, and two certificates can make the
/// same claim. The bytes are the credential.
pub fn peer_certificate_der<T>(request: &tonic::Request<T>) -> Option<Vec<u8>> {
    let certs = request.peer_certs()?;
    Some(certs.first()?.as_ref().to_vec())
}

/// **The identity core's IdentityService, as this server mounts it** (IDENTITY-HOST): edda's
/// one adapter over `identity`. Who the `client` field names is believed as the console relays
/// it (RA5-32 follow-up, `ClientTrust`): the console reaches this listener from the front, off
/// loopback, and the listener's transport (mTLS off loopback) authenticates every such peer.
pub fn identity_service(
    identity: Arc<crate::identity::GunnarIdentity>,
) -> nornir_identity::grpc::IdentityGrpc<crate::identity::GunnarHost> {
    nornir_identity::grpc::IdentityGrpc::new(identity)
        .trust_clients(nornir_identity::grpc::ClientTrust::EveryPeer)
}

pub fn router(ctx: ServiceContext, request_ids: RequestIdSource) -> GunnarRouter {
    // `None` is a plaintext listener, which is what the loopback default is
    // for. `router_with_trust` is the same wiring with the door locked.
    router_with_trust(ctx, request_ids, None).expect("a router without TLS cannot fail to build")
}

/// [`router`], with an optional [`Trust`] making the listener **mutually**
/// authenticated.
///
/// # Why this is a separate function and not a field on `ServerConfig`
///
/// `ServerConfig` crosses into `gunnar-proto` and is cloned into every
/// [`ServiceContext`]; putting three PEM blobs — one of them a private key —
/// into a struct that is copied per service is a way to end up with key
/// material in a `Debug` line. The trust root is consumed once, here, at the
/// point the listener is built, and never travels further.
///
/// # What `Some(trust)` actually changes
///
/// Two things, and only together are they mTLS:
///
/// 1. the transport requires a client certificate the pinned CA signed — a
///    peer with none, or with one from another CA, never completes a
///    handshake and never reaches a service;
/// 2. [`AuthGate`] can then read that certificate off the connection and turn
///    it into an ACL principal, so a certificate the CA signed but nobody
///    registered is refused at the gate with an audit record rather than
///    admitted.
///
/// Without (1) the gate would never see a certificate; without (2) any holder
/// of any CA-signed certificate would be every principal at once. The three
/// red-first tests in `gunnar-cli/tests/mtls_handshake.rs` pin one refusal each.
/// Pin **ring** as this process's rustls provider, once.
///
/// Two providers are linked into a gunnar server and neither is a mistake:
/// `russh` brings `aws-lc-rs` (the AEAD side of the crypto rule) and tonic's
/// `tls-ring` brings `ring` (the TLS side). rustls will not guess between them,
/// and the way it declines is a **panic on the first handshake** — inside the
/// control plane's own runtime thread, where it shows up to a client as a
/// connection refused and to an operator as nothing at all. That is precisely
/// how it presented the first time this listener was given a certificate.
///
/// So the choice is made here, in code, before a listener can be built.
/// `install_default` returning `Err` means some other caller got there first;
/// that is the desired end state, not a failure, and it is ignored for the same
/// reason `gunnar-http` ignores it — two crates pinning the *same* provider
/// must not race each other into a startup error.
///
/// # TLS 1.3 ONLY, and why it is done HERE and not on the config
///
/// The owner's ruling is TLS 1.3 with **no downgrade and no negotiation
/// surface**. tonic 0.14 does not expose one: both halves build their rustls
/// config with `with_safe_default_protocol_versions()`
/// (`tonic-0.14.6/src/transport/channel/service/tls.rs:48` and
/// `.../server/service/tls.rs:33`), which is TLS 1.2 **and** 1.3, and neither
/// `ClientTlsConfig` nor `ServerTlsConfig` has a version knob to turn it off.
///
/// The lever that *is* reachable is the process-wide provider tonic reads with
/// `CryptoProvider::get_default()`. A provider carrying **only TLS 1.3 cipher
/// suites** cannot complete a TLS 1.2 handshake in either direction: the client
/// offers no 1.2 suite and the server matches none. The version list stays at
/// its default and stops mattering, which is the same end state by the one road
/// this dependency actually leaves open.
pub fn ensure_crypto_provider() {
    let _ = tls13_only_provider().install_default();
}

/// `ring`, with every cipher suite that is not TLS 1.3 removed.
///
/// Built from `ring::default_provider()` rather than a hand-written suite list
/// on purpose: a list written here would silently stop matching the day rustls
/// adds or retires a 1.3 suite, and the failure mode of a stale ALLOW list is a
/// handshake that stops working for a reason nobody can find. Filtering by the
/// suite's own `version()` cannot go stale.
pub fn tls13_only_provider() -> rustls::crypto::CryptoProvider {
    let mut provider = rustls::crypto::ring::default_provider();
    provider
        .cipher_suites
        .retain(|suite| suite.version().version == rustls::ProtocolVersion::TLSv1_3);
    provider
        .cipher_suites
        .sort_by_key(|suite| bulk_rank(suite.suite()));
    provider
}

/// Preference order for the three TLS 1.3 suites, **for bulk transfer**.
///
/// rustls takes the order of `cipher_suites` as preference, so this is the
/// whole mechanism. Lower sorts first.
///
/// The ranking is not about strength — all three are AEADs nobody has broken —
/// it is about where the bytes are encrypted, and it is decided by
/// [`aes_is_accelerated`]:
///
/// * **With AES-NI** (every x86-64 UpCloud VM in this estate, and the check
///   below says so rather than assuming it): `TLS_AES_128_GCM_SHA256` first.
///   Two reasons, and the second is the one that decides it. AES-128 has four
///   fewer rounds than AES-256 for a key length that is nowhere near this
///   design's weakest link. And **WireGuard underneath is ChaCha20-Poly1305 and
///   that is fixed** — Noise_IK names it, there is no negotiation — so an
///   AES-GCM record layer puts the two encryptions on different execution units
///   (AES-NI and PCLMULQDQ against the SIMD ports ChaCha's ARX rounds saturate)
///   instead of queueing them behind each other.
/// * **Without AES-NI**: `TLS_CHACHA20_POLY1305_SHA256` first, because a
///   software AES-GCM is several times slower than ChaCha20 and the
///   different-execution-unit argument is worth nothing when one of the units
///   does not exist. The layering argument INVERTS with the hardware, which is
///   why it is read at runtime and not written into a constant.
fn bulk_rank(suite: rustls::CipherSuite) -> u8 {
    let accelerated = aes_is_accelerated();
    match suite {
        rustls::CipherSuite::TLS13_AES_128_GCM_SHA256 => u8::from(!accelerated),
        rustls::CipherSuite::TLS13_CHACHA20_POLY1305_SHA256 => u8::from(accelerated),
        // AES-256-GCM: same hardware path, more rounds. Last either way — it is
        // kept rather than dropped so a peer that somehow offers only this one
        // still connects, which is a different thing from preferring it.
        _ => 2,
    }
}

/// Does this CPU do AES and carry-less multiply in hardware?
///
/// `is_x86_feature_detected!` is a CPUID read through `std`, so it reports what
/// the **guest** was actually given rather than what the host has — which is the
/// distinction that matters on a VM, and the reason this is not a compile-time
/// `cfg`. A hypervisor that masks AES-NI out of the guest flips
/// [`bulk_rank`] on that box and nowhere else.
///
/// Off x86-64 this answers `false`, which selects ChaCha20 — the right default
/// for every architecture whose AES story is not AES-NI.
pub fn aes_is_accelerated() -> bool {
    #[cfg(target_arch = "x86_64")]
    {
        std::arch::is_x86_feature_detected!("aes")
            && std::arch::is_x86_feature_detected!("pclmulqdq")
    }
    #[cfg(not(target_arch = "x86_64"))]
    {
        false
    }
}

/// The suite this process will prefer for bulk transfer, for `Admin.Health` and
/// for an operator asking why a transfer is the speed it is.
pub fn preferred_bulk_suite() -> &'static str {
    if aes_is_accelerated() {
        "TLS13_AES_128_GCM_SHA256 (AES-NI present; WireGuard's ChaCha20 underneath runs on the other units)"
    } else {
        "TLS13_CHACHA20_POLY1305_SHA256 (NO AES-NI on this CPU; software AES-GCM would be the slower half)"
    }
}

/// Every cipher suite the **installed** provider offers that is not TLS 1.3.
///
/// Empty is the good answer. This is deliberately a question about what is
/// installed and not about what [`tls13_only_provider`] would have built:
/// `install_default` is first-caller-wins and this process links three crates
/// that pin a provider (`gunnar-grpc`, `gunnar-http`, and `russh`'s aws-lc-rs).
/// Whether the 1.3-only one won is therefore an **order-dependent fact about
/// this boot**, and an order-dependent security property that nothing reads
/// back is the health line that cannot fail. [`refuse_unless_tls13_only`] is
/// the reader.
///
/// `None` means no provider is installed at all, which is a different fact from
/// "installed and clean" and is never reported as one.
pub fn non_tls13_suites() -> Option<Vec<String>> {
    let provider = rustls::crypto::CryptoProvider::get_default()?;
    Some(
        provider
            .cipher_suites
            .iter()
            .filter(|suite| suite.version().version != rustls::ProtocolVersion::TLSv1_3)
            .map(|suite| format!("{:?}", suite.suite()))
            .collect(),
    )
}

/// The refusal a listener or a box-to-box client makes **before** the first
/// handshake, when this process would negotiate below TLS 1.3.
///
/// Called from [`router_with_trust`] and from
/// [`client_signer::mutual_channel`], so both ends of every gunnar-to-gunnar
/// link ask the same question of the same provider.
pub fn refuse_unless_tls13_only() -> anyhow::Result<()> {
    match non_tls13_suites() {
        None => anyhow::bail!(
            "no rustls crypto provider is installed in this process, so nothing decides which \
             cipher suites a handshake may use. `ensure_crypto_provider()` must run before any \
             TLS listener is built or any TLS channel is dialled."
        ),
        Some(offending) if !offending.is_empty() => anyhow::bail!(
            "this process's rustls provider offers {} cipher suite(s) BELOW TLS 1.3 ({}), so a \
             peer could negotiate TLS 1.2 on a link the owner ruled is TLS 1.3 only. Some other \
             crate installed its provider before `ensure_crypto_provider()` did — that call has \
             to happen first, in the binary's own start-up, not on the first handshake.",
            offending.len(),
            offending.join(", ")
        ),
        Some(_) => Ok(()),
    }
}

pub fn router_with_trust(
    ctx: ServiceContext,
    request_ids: RequestIdSource,
    trust: Option<&Trust>,
) -> anyhow::Result<GunnarRouter> {
    // The context's guard, so `Bootstrap.Restore`'s own check spends from the same one.
    let gate = AuthGate::new(Arc::clone(&ctx.control), ctx.config.auth)
        .with_replay_guard(Arc::clone(&ctx.replay))
        .with_registered_certificate_required(ctx.config.require_registered_client_certificate)
        // The SAME log `Admin.Health` reads out of the context. One `Arc`, so
        // the v1 refusals an operator reads there are the ones the gate
        // actually made (one implementation).
        .with_unbound_log(Arc::clone(&ctx.unbound))
        // A person's identity-core session is a credential (IDENTITY-HOST, A34-18).
        .with_identity(ctx.identity.clone());
    let builder = tonic::transport::Server::builder();
    let builder = match trust {
        // `?`, not `expect`: the PEM comes from files an operator named, and a
        // key that does not parse must stop the server with the reason rather
        // than panic inside the listener.
        Some(trust) => {
            ensure_crypto_provider();
            // **Read back what was actually installed.** `install_default` is
            // first-caller-wins, so the line above is a request and not a
            // guarantee; this is the check that can fail, and it fails BEFORE
            // the listener binds rather than on some peer's handshake months
            // later.
            refuse_unless_tls13_only()?;
            builder.tls_config(trust.server_tls())?
        }
        None => builder,
    };
    // ── why there is NO coarse control-plane concurrency cap here ───────────
    //
    // The transfer-shaped RPC (`Repos.ArchiveOut`) takes a real admission
    // permit inside its handler (`ServiceContext::transfer_gate`), because it
    // streams a whole repository's stored bytes down this channel and nothing
    // else budgets that. The *cheap* metadata RPCs deliberately get no gate:
    // the control plane already runs on a runtime of its own
    // (`gunnar-server/src/serve.rs`), so an expensive control call — a
    // `StorageInfo`/`VerifyChain`/`ListOrphans` disk walk — blocks the control
    // plane's OWN workers and can never starve the git listeners it was once
    // able to. Bounding it further with a `concurrency_limit_per_connection` +
    // `load_shed` would add a gate `design.md` deliberately omits (the control
    // surface is a low-cardinality, mTLS-gated admin plane, priced by the
    // read-cost model, not by an admission ledger). So the gate stays on the
    // one genuine transfer and off the metadata, on purpose.
    let router = builder
        // ── the span, OUTSIDE every service and every interceptor ──────────
        //
        // Applied to the builder rather than to a service, so it wraps the
        // whole `Routes` table: the seven gated services, the ungated
        // `Bootstrap`, and a request for a method that does not exist. The gate
        // is an interceptor and therefore runs INSIDE this, which is what makes
        // the audit record inherit `req` — and why a refusal by the gate is
        // attributable at all. See `trace` for why an interceptor could not do
        // this job.
        .layer(RequestSpanLayer::new(request_ids))
        // ── the call, OFF the connection's runtime (R5.1-G) ────────────────
        //
        // Inside the span (so the call carries `req`), outside every service
        // and interceptor (so the gate's own store reads move too). The
        // handlers make synchronous store calls; on the runtime that drives
        // the HTTP/2 connections, enough of them at once left no worker to
        // answer a keepalive PING and the client killed the connection with
        // every call on it. See `offload`.
        .layer(offload::OffloadLayer::new(ctx.handlers.clone()))
        .add_service(ReposServer::with_interceptor(
            ReposService::new(ctx.clone()),
            gate.clone(),
        ))
        // Gated like every other mutating service. `Accounts` is the one whose
        // verbs cannot be undone from this box at all, so it is mounted inside
        // the gate beside `Access` rather than anywhere near `Bootstrap`.
        .add_service(AccountsServer::with_interceptor(
            AccountsService::new(ctx.clone()),
            gate.clone(),
        ))
        // Clans (2026-10-05): the person's own verbs, decided from the clan.
        .add_service(ClansServer::with_interceptor(
            ClansService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(RefsServer::with_interceptor(
            RefsService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(AccessServer::with_interceptor(
            AccessService::new(ctx.clone()),
            gate.clone(),
        ))
        // The owner's enterprise list (2026-10-05): the account admin's audit
        // log, the operator's aggregates and look, the name filter.
        .add_service(EnterpriseServer::with_interceptor(
            EnterpriseService::new(ctx.clone()),
            gate.clone(),
        ))
        // Company SSO: behind the same gate — the account admin's verbs need
        // the person's own credential, the relay's the console's.
        .add_service(SsoServer::with_interceptor(
            SsoService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(PolicyServer::with_interceptor(
            PolicyService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(EventsServer::with_interceptor(
            EventsService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(AdminServer::with_interceptor(
            AdminService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(MirrorServer::with_interceptor(
            MirrorService::new(ctx.clone()),
            gate.clone(),
        ))
        // The meters. Open source, always on — see `service::usage`.
        .add_service(UsageServer::with_interceptor(
            UsageService::new(ctx.clone()),
            gate.clone(),
        ))
        // Many namespaces' meters in one call (2026-10-06): the poller's read.
        .add_service(UsageBatchServer::with_interceptor(
            UsageBatchService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(WebhooksServer::with_interceptor(
            WebhooksService::new(ctx.clone()),
            gate.clone(),
        ))
        .add_service(PullRequestsServer::with_interceptor(
            PullsService::new(ctx.clone()),
            gate.clone(),
        ))
        // The review half. `PullRequests` owns the proposal as an object; this
        // owns what people SAID about it, as signed evidence. See the module
        // docs of `service::forge` for why they are two services and not one.
        .add_service(ForgeServer::with_interceptor(
            ForgeService::new(ctx.clone()),
            gate.clone(),
        ));
    // ── the METERED appliance's service, and only its ─────────────────────
    //
    // `cfg`, not a runtime flag: without the feature there is no
    // `EntitlementServer` type to mount, no handler, no proto — the symbol
    // table of the community binary is the proof (the metered build split).
    #[cfg(feature = "metered")]
    let router = router.add_service(EntitlementServer::with_interceptor(
        EntitlementService::new(ctx.clone()),
        gate.clone(),
    ));
    // The metered appliance's SECOND service: the tenant's own renewal path.
    // Behind the same gate as everything else — the credential it needs is the
    // human's own, and the scope check inside the handler is worth nothing if
    // the caller it checks was not authenticated first.
    #[cfg(feature = "metered")]
    let router = router.add_service(BillingServer::with_interceptor(
        BillingService::new(ctx.clone()),
        gate.clone(),
    ));
    // The metered appliance's named sealed slot (2026-10-09), behind the gate.
    #[cfg(feature = "metered")]
    let router = router.add_service(SealedServer::with_interceptor(
        SealedService::new(ctx.clone()),
        gate.clone(),
    ));
    // ── the identity core's service, outside the gate on purpose ──────────
    //
    // `nornir.identity.v1.IdentityService` (IDENTITY-HOST): its sign-up, login
    // and redeem verbs are the ones a person calls BEFORE they hold any
    // credential, so a gate would refuse exactly them; every other verb checks
    // the session it carries inside the core. The listener's own transport
    // (mTLS off loopback) still decides who reaches it at all.
    let router = match &ctx.identity {
        Some(identity) => router.add_service(identity_service(Arc::clone(identity)).into_server()),
        None => router,
    };
    drop(gate);
    Ok(router
        // ── the ONE service mounted outside the gate ───────────────────────
        //
        // `with_interceptor` is deliberately absent here and its absence is
        // load-bearing: see `service::bootstrap` and
        // [`auth::UNAUTHENTICATED_METHODS`]. The gate answers out of the ACL key
        // table, and on a server that has never been enrolled that table is
        // empty, so a gated `Bootstrap` could never be reached by anybody —
        // which is the defect it exists to fix.
        //
        // Being outside the gate is not being unguarded. `Bootstrap.Enroll`'s
        // refusal lives under the compare-and-swap in `gunnar-core` that writes
        // the first identity, so it holds even against a handler that forgot to
        // check and against two callers racing.
        .add_service(BootstrapServer::new(BootstrapService::new(ctx))))
}

/// Serve the control plane until the process is killed.
///
/// Binds [`ServerConfig::bind`], loopback by default; the crate docs say why
/// that matters.
pub async fn serve(
    config: ServerConfig,
    control: Arc<dyn ControlPlane>,
    bus: Arc<EventBus>,
    request_ids: RequestIdSource,
) -> anyhow::Result<()> {
    warn_authentication_disabled(&config);
    let bind = config.bind;
    let ctx = ServiceContext::new(control, bus, config);
    tracing::info!(%bind, "gunnar gRPC control plane listening");
    router(ctx, request_ids).serve(bind).await?;
    Ok(())
}

/// [`serve`], with a shutdown signal.
pub async fn serve_with_shutdown<F>(
    config: ServerConfig,
    control: Arc<dyn ControlPlane>,
    bus: Arc<EventBus>,
    request_ids: RequestIdSource,
    shutdown: F,
) -> anyhow::Result<()>
where
    F: std::future::Future<Output = ()> + Send + 'static,
{
    warn_authentication_disabled(&config);
    let bind = config.bind;
    let ctx = ServiceContext::new(control, bus, config);
    tracing::info!(%bind, "gunnar gRPC control plane listening");
    router(ctx, request_ids)
        .serve_with_shutdown(bind, shutdown)
        .await?;
    Ok(())
}

#[cfg(test)]
mod trust_tests {
    use super::Trust;

    /// **There is no unit test that `server_tls` is mutual, and that is a fact
    /// worth stating rather than a gap to paper over.**
    ///
    /// `client_ca_root` is what makes the listener mutual: without it the config
    /// is ordinary TLS — it encrypts, it presents a server certificate, and it
    /// accepts a client that offered nothing. Every connection still succeeds,
    /// so no test that merely connects can tell the two apart.
    ///
    /// The obvious guard was tried and is a hollow one: `ServerTlsConfig` has an
    /// opaque `Debug` (`"ServerTlsConfig"`, no fields) and no accessors, so a
    /// config WITH a client CA and one WITHOUT are indistinguishable from
    /// outside. A test comparing them passes whatever `server_tls` does, which
    /// is worse than no test — it would report green while the door stood open.
    ///
    /// The real proof is three handshakes against a live listener, each asserted
    /// REFUSED before anything is asserted accepted:
    ///
    ///  1. a client presenting **no certificate**;
    ///  2. a client whose certificate a **different CA** signed — `trust`'s own
    ///     test already shows an unrelated CA refuses what this one signed, so
    ///     the material exists;
    ///  3. a client signed by this CA whose fingerprint is **not in the
    ///     `AuthTable`** — which must reach the gate and be refused there, with
    ///     the same audit record any unknown credential produces.
    ///
    /// Until those exist, this listener is **configured** for mTLS and not
    /// **proven** to enforce it. See the mTLS plan.
    #[test]
    fn the_trust_config_is_built_from_all_three_parts() {
        let t = Trust {
            client_ca: b"ca".to_vec(),
            cert: b"cert".to_vec(),
            key: b"key".to_vec(),
        };
        // What can honestly be asserted here: the parts are carried and none is
        // silently dropped on the way in.
        assert!(
            !t.client_ca.is_empty(),
            "a mutual listener needs a client CA"
        );
        assert!(!t.cert.is_empty() && !t.key.is_empty(), "and an identity");
        let _ = t.server_tls();
    }

    /// The three files are read as given, without interpretation.
    ///
    /// `from_files` deliberately does not parse or validate: a certificate is
    /// checked by the TLS stack at handshake time against the CA, and a second
    /// weaker opinion here is the one an attacker would prefer gunnar to hold.
    #[test]
    fn from_files_reads_all_three_and_keeps_them_distinct() {
        let dir = tempfile::tempdir().expect("tempdir");
        for (name, body) in [("ca", "CA"), ("cert", "CERT"), ("key", "KEY")] {
            std::fs::write(dir.path().join(name), body).expect("write");
        }
        let t = Trust::from_files(
            &dir.path().join("ca"),
            &dir.path().join("cert"),
            &dir.path().join("key"),
        )
        .expect("reading");
        assert_eq!(t.client_ca, b"CA", "the client CA must be the ca file");
        assert_eq!(t.cert, b"CERT", "the identity must be the cert file");
        assert_eq!(t.key, b"KEY", "the key must be the key file");
    }
}