crates/gunnar-grpc/src/config.rs
//! Listener configuration: the loopback default, and the authentication mode.
//!
//! The loopback default (the two-listener split) predates gRPC authentication: it was what kept
//! the allow-all hole *local* rather than *remote*. Authentication has landed, and the
//! default stays anyway. Defence in depth costs nothing here, every real
//! deployment reaches gRPC over an SSH tunnel or from the same host, and
//! `tests/loopback_default.rs` still goes red if anyone widens it.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
pub use crate::auth::AuthMode;
/// **The default gRPC bind address. It is loopback, and that is load-bearing.**
///
/// v1 gRPC is unauthenticated by decision. Anyone who can reach this port
/// is an admin of the control plane. Binding to `127.0.0.1` makes that hole a
/// *local* one rather than a *remote* one, and it costs nothing; every real
/// deployment reaches gRPC over an SSH tunnel or from a process on the same
/// host anyway.
///
/// Changing this to `0.0.0.0` turns a known, contained hole into a remotely
/// exploitable one. `default_bind_is_loopback` in `tests/loopback_default.rs`
/// goes red if anyone does.
pub const DEFAULT_BIND: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 50051);
/// Whether this build of `gunnar-grpc` implements gRPC authentication.
///
/// **`true` since the allow-all hole was closed.** It is a statement about the *crate*, not
/// about one listener: a server started through
/// [`ServerConfig::insecure_allow_all_for_local_development`] still has the
/// code and still refuses nothing, which is why `Admin.Health` reports
/// [`ServerConfig::authentication_enabled`], the runtime answer, rather than
/// this constant.
pub const AUTHENTICATION_ENABLED: bool = true;
/// How many events the event broadcast bus retains for subscribers that have
/// not read them yet. This is a *hard* bound: past it, the oldest events are
/// dropped and the affected subscriber is handed an `EventGap`. It is never a
/// growth budget.
pub const DEFAULT_EVENT_BUFFER: usize = 1024;
#[derive(Debug, Clone)]
pub struct ServerConfig {
/// Where the gRPC control plane listens. Loopback by default; see
/// [`DEFAULT_BIND`].
pub bind: SocketAddr,
/// Capacity of the event bus ring. See [`DEFAULT_EVENT_BUFFER`].
pub event_buffer: usize,
/// Informational only: where the SSH listener is bound, so `Admin.Health`
/// can report both listeners in one place (they are separate, and an
/// operator should be able to see that). `gunnar-cli` fills it in; this
/// crate never binds it.
pub ssh_bind_hint: String,
/// Whether a credential is required. [`AuthMode::Required`] by default and
/// in every deployment; see
/// [`ServerConfig::insecure_allow_all_for_local_development`] for the one
/// way to get anything else.
pub auth: AuthMode,
/// **Does this listener demand a client certificate?** (`--grpc-tls-self`.)
///
/// A `bool` and deliberately nothing more. The trust root itself — two
/// certificates and a private key — is consumed once by
/// [`crate::router_with_trust`] and never enters this struct, which is
/// cloned into every [`crate::ServiceContext`] and printed by `Debug`;
/// that argument is written out at `router_with_trust`. What an operator
/// needs on the wire is not the material but the *fact*, because the
/// transition to TLS is a restart with a changed flag and the only way to
/// confirm it took is to ask the server.
///
/// `false` here on a listener that IS mutual would be a lie of exactly the
/// kind `grpc_authentication_enabled` was told for months, so it is set in
/// the same `match` that builds the trust root and from the same value.
pub mutual_tls: bool,
/// **Must the peer's client certificate be REGISTERED to a principal**, not
/// only signed by the issuer the listener pins?
///
/// `true` on the mutual REPLICATION door only (`serve::replication_door`),
/// where the certificate is the twin's pairing: the handshake alone accepts
/// any certificate the box's issuer ever signed, and a request that also
/// carries a header is judged by the header alone — so a revoked twin
/// certificate (a nuked twin's, superseded when its replacement was
/// certified, twin reborn 2026-09-30) still opened the door as long as the
/// dead twin's ssh key signed the call. With this on, a certificate no
/// principal holds (revoked, superseded, expired) is refused at the gate
/// whatever else the call carries.
pub require_registered_client_certificate: bool,
/// **The WebAuthn relying parties this server answers for**
/// (`--web-relying-party`, stinky row 13). Empty: the relay's `rp_id` and
/// `origin` are taken as stated, as before. Otherwise every ceremony's
/// `(rp_id, origin)` must be one of these, so a relay cannot run one for a
/// relying party it merely names.
pub web_relying_parties: Vec<crate::webauthn::RelyingParty>,
/// **Whether every passkey ceremony must verify the user**
/// (`--web-require-user-verification`, A34-12): the server's word; a relay
/// may raise it for one ceremony, never lower it.
pub web_user_verification: nornir_identity_session::step_up::UserVerification,
}
/// Whether a passkey ceremony must verify the user (edda, A34-12).
pub use nornir_identity_session::step_up::UserVerification;
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: DEFAULT_BIND,
event_buffer: DEFAULT_EVENT_BUFFER,
ssh_bind_hint: String::new(),
// Deny by default. Not derived from anything, not read from the
// environment, not conditional on the bind address: a config that
// has been constructed and not spoken to requires a credential.
auth: AuthMode::Required,
mutual_tls: false,
require_registered_client_certificate: false,
web_relying_parties: Vec::new(),
web_user_verification: nornir_identity_session::step_up::UserVerification::Preferred,
}
}
}
impl ServerConfig {
/// Override the bind address.
///
/// A non-loopback address is *allowed*, since an operator behind a firewall
/// may genuinely want it, but while [`AUTHENTICATION_ENABLED`] is false it
/// is logged at `error!` on every construction, because it turns the v1
/// hole into a remote one. The log is not decoration: it is the only thing
/// between this decision and an open admin port.
pub fn with_bind(mut self, bind: SocketAddr) -> Self {
self.bind = bind;
if !self.authentication_enabled() && !self.is_loopback_bind() {
tracing::error!(
bind = %self.bind,
"gunnar-grpc bound to a NON-LOOPBACK address with the allow-all development \
escape hatch ON. Every control-plane mutation — principals, keys, grants, \
branch protection, repo create/delete — is now reachable by anyone who can \
reach this port. Git push/fetch are NOT affected (separate SSH listener, \
publickey auth), but this is an open admin surface. Drop the escape hatch, \
or bind loopback."
);
}
self
}
/// **DEVELOPMENT ONLY: turn gRPC authentication OFF for this listener.**
///
/// This restores the v1 allow-all behaviour: no credential, no grant check,
/// every RPC served to every caller. It exists because a developer with a
/// freshly built server and no key registered still has to be able to reach
/// it, and a named switch is a better answer than an undocumented hole.
///
/// It is loud in four places, by design:
///
/// 1. `warn!` here, the moment it is constructed;
/// 2. `warn!` again from [`crate::warn_authentication_disabled`] at every
/// server start;
/// 3. `Admin.Health` reports `grpc_authentication_enabled: false`;
/// 4. every audit record it produces carries
/// `auth_mode="allow-all-insecure"` and
/// `principal="(unauthenticated: allow-all-insecure)"`.
///
/// It is **never** the default, is not reachable from an environment
/// variable, and there is no way to reach it from a config file. An
/// operator has to write this method name into a program.
pub fn insecure_allow_all_for_local_development(mut self) -> Self {
self.auth = AuthMode::AllowAllInsecure;
tracing::warn!(
bind = %self.bind,
"gRPC AUTHENTICATION HAS BEEN TURNED OFF for this listener \
(insecure_allow_all_for_local_development). Every control-plane mutation \
(principals, keys, grants, branch protection, repo create/delete) is served to \
any caller that can reach this port. This is a LOCAL DEVELOPMENT switch and must \
never appear in a deployment."
);
self
}
/// Whether **this listener** requires a credential.
///
/// This, not [`AUTHENTICATION_ENABLED`], is what `Admin.Health` reports: a
/// build that has authentication and a listener that is serving allow-all
/// are different facts, and an operator needs the second one.
pub fn authentication_enabled(&self) -> bool {
self.auth.is_required()
}
/// The WebAuthn relying parties this server answers for
/// (`--web-relying-party`). See [`ServerConfig::web_relying_parties`].
pub fn with_web_relying_parties(mut self, parties: Vec<crate::webauthn::RelyingParty>) -> Self {
self.web_relying_parties = parties;
self
}
/// Whether every passkey ceremony must verify the user (A34-12).
pub fn with_web_user_verification(
mut self,
policy: nornir_identity_session::step_up::UserVerification,
) -> Self {
self.web_user_verification = policy;
self
}
/// Refuse a peer whose client certificate is not registered to a
/// principal, whatever header it sends (the replication door).
pub fn with_registered_client_certificate_required(mut self, require: bool) -> Self {
self.require_registered_client_certificate = require;
self
}
/// Say that this listener was built with a [`crate::Trust`]
/// (`--grpc-tls-self`).
///
/// Reported, not enforced: the enforcement is `client_ca_root` on the
/// transport, and this is how an operator sees that it is there.
pub fn with_mutual_tls(mut self, mutual: bool) -> Self {
self.mutual_tls = mutual;
self
}
pub fn with_event_buffer(mut self, event_buffer: usize) -> Self {
self.event_buffer = event_buffer.max(1);
self
}
pub fn with_ssh_bind_hint(mut self, hint: impl Into<String>) -> Self {
self.ssh_bind_hint = hint.into();
self
}
/// True when [`Self::bind`] is a loopback address.
///
/// This is the predicate the loopback test asserts on. It must be able to
/// answer *false*. A predicate that is true for every input would pass the
/// test while proving nothing (the identity-value trap: measure, never assert), so the
/// test asserts both directions.
pub fn is_loopback_bind(&self) -> bool {
self.bind.ip().is_loopback()
}
}