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");
}
}