gunnar-grpc 1.5.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! **The client half of the credential**: sign one fresh, method-bound,
//! single-use credential per outgoing control-plane call.
//!
//! Lived in `gunnar-cli` until 2026-09-15; moved here (one implementation)
//! the day a SERVER needed to make credentialled calls — the twin follows its
//! primary over `Mirror.LogHead` / `Mirror.LogFetch` from inside
//! `gunnar-server`, and a second signer there would have been the v1
//! method-blind shape all over again the first time the two drifted.

use std::sync::Arc;

use tonic::transport::Channel;
/// The endpoint a channel tune works on ([`keep_alive`]), named for callers that link no tonic.
pub use tonic::transport::Endpoint;

/// A `Channel` with the signer between the client and the transport. Not a
/// bare `Channel` since the credential started binding the method (GUN-001):
/// the method a call is about to make is `tonic::GrpcMethod`, which the
/// generated client puts in the request extensions *after* the caller has
/// built the request — so the only place that sees the key and the method at
/// once is an interceptor.
pub type ControlChannel = tonic::service::interceptor::InterceptedService<Channel, CredentialSigner>;

/// The key [`authed`] left in the request, for [`CredentialSigner`] to sign
/// with once the method is known. An `Arc` rather than the key: extensions
/// are cloned with the request, and a private key that is memcpy'd per RPC is
/// a private key in more places than it needs to be.
#[derive(Clone)]
pub struct SigningKey(pub Arc<ssh_key::PrivateKey>);

/// Signs one credential per outgoing call. No [`SigningKey`] in the request
/// means the caller deliberately made an unauthenticated call
/// (`Bootstrap.Enroll`, `Bootstrap.Status`) and the header is left off.
#[derive(Clone, Copy, Debug)]
pub struct CredentialSigner;

impl tonic::service::Interceptor for CredentialSigner {
    fn call(&mut self, mut request: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
        use rand::Rng as _;

        let Some(SigningKey(key)) = request.extensions().get::<SigningKey>().cloned() else {
            return Ok(request);
        };
        let method = request
            .extensions()
            .get::<tonic::GrpcMethod>()
            .map(|m| {
                let service = m.service().rsplit('.').next().unwrap_or(m.service());
                format!("{service}.{}", m.method())
            })
            .ok_or_else(|| {
                tonic::Status::internal(
                    "this call carries a signing key but no tonic::GrpcMethod, so the credential \
                     cannot be bound to the method it is about to be spent on",
                )
            })?;
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map_err(|_| tonic::Status::internal("the system clock is before 1970"))?
            .as_millis() as i64;
        let mut bytes = [0u8; 24];
        rand::rand_core::UnwrapErr(rand::rngs::SysRng).fill_bytes(&mut bytes);
        let nonce = bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
        // A FIDO2 security key signs through ssh-agent, with a touch
        // (`security_key`); every other key in-process.
        let credential = if crate::security_key::is_security_key(key.public_key()) {
            crate::security_key::sign_credential_with_agent(key.public_key(), now, &nonce, &method)
                .map_err(tonic::Status::failed_precondition)?
        } else {
            crate::sign_credential_for(&key, now, &nonce, &method)
                .map_err(|err| tonic::Status::internal(format!("signing the control-plane credential: {err}")))?
        };
        let value = credential
            .to_header_value()
            .parse()
            .map_err(|_| tonic::Status::internal("the credential is not a legal header value"))?;
        request.metadata_mut().insert(crate::CREDENTIAL_METADATA_KEY, value);
        Ok(request)
    }
}

/// A request that [`CredentialSigner`] will sign with `key` once the method
/// is known. A new nonce every time: the server remembers accepted
/// `(fingerprint, nonce)` pairs and refuses a repeat.
pub fn authed<T>(key: &ssh_key::PrivateKey, message: T) -> tonic::Request<T> {
    let mut request = tonic::Request::new(message);
    request.extensions_mut().insert(SigningKey(Arc::new(key.clone())));
    request
}

/// A plain `http://` channel to `at` (`host:port`), signed by [`CredentialSigner`].
///
/// # It refuses to leave this box
///
/// Until 2026-09-21 this function's own doc comment read *"a box-to-box call on
/// the private network is the cleartext shape"*, and its one production caller
/// was the twin's replication source (`gunnar-server`'s `twin.rs`). What
/// crosses that channel is the primary's transaction log, and **seq 1 of that
/// log is the KEK**: the estate's key material was moving between machines in
/// clear text, authenticated by a single-use sshsig credential that an on-path
/// observer can read and replay.
///
/// So the cleartext shape is now a **loopback-only** shape, and this is the
/// check that says so. A non-loopback target is refused BY NAME and names
/// [`mutual_channel`] as the way across. There is no flag to turn this off:
/// a plaintext fallback that an operator can reach is the fallback every
/// misconfiguration takes.
///
/// A hostname is refused too, and deliberately — `localhost` is the only one
/// accepted, resolved here rather than by a resolver, because "is this name
/// loopback?" answered by DNS is a question an attacker can answer.
pub fn plain_channel(at: &str) -> Result<ControlChannel, String> {
    plain_channel_with(at, |e| e)
}

/// **A channel tune that keeps an HTTP/2 connection alive**: a PING every
/// `every`, idle or not, and the connection dropped when one is not answered
/// within `within` — a path that died under an open connection is found by
/// the next missed PING, not by a stream that waits for ever. For
/// [`plain_channel_with`] / [`mutual_channel_with`].
pub fn keep_alive(every: std::time::Duration, within: std::time::Duration) -> impl FnOnce(tonic::transport::Endpoint) -> tonic::transport::Endpoint {
    move |e| e.http2_keep_alive_interval(every).keep_alive_timeout(within).keep_alive_while_idle(true)
}

/// [`plain_channel`], its endpoint tuned by `tune` before it is dialled (the
/// twin's replication channel: HTTP/2 keepalive).
pub fn plain_channel_with(at: &str, tune: impl FnOnce(tonic::transport::Endpoint) -> tonic::transport::Endpoint) -> Result<ControlChannel, String> {
    refuse_if_off_box(at)?;
    let url = if at.contains("://") { at.to_owned() } else { format!("http://{at}") };
    let endpoint = Channel::from_shared(url)
        .map_err(|e| format!("{at:?} is not an address the control plane can be reached at: {e}"))?;
    let channel = tune(endpoint).connect_lazy();
    Ok(tonic::service::interceptor::InterceptedService::new(channel, CredentialSigner))
}

/// The host part of `at`, with any scheme and port stripped.
///
/// `at` is either `host:port` or a URL; both shapes reach this function because
/// both shapes reach [`plain_channel`].
fn host_of(at: &str) -> &str {
    let rest = match at.split_once("://") {
        Some((_, rest)) => rest,
        None => at,
    };
    let rest = rest.split('/').next().unwrap_or(rest);
    // `[::1]:50051` — an IPv6 literal keeps its brackets through the port split.
    if let Some(inner) = rest.strip_prefix('[') {
        return inner.split(']').next().unwrap_or(inner);
    }
    match rest.rsplit_once(':') {
        Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => host,
        _ => rest,
    }
}

/// **The name a primary's mutual replication listener proves, and a twin checks.**
///
/// A fixed service name rather than an address, on purpose. The twin reaches its
/// primary at whatever the path makes it — a WireGuard address, a NAT'd
/// underlay, `10.0.2.2` through a hypervisor's slirp — and none of those is
/// known to the primary when it mints its leaf at start. What the name has to
/// prove is only "a server leaf from the issuer this twin PINNED", and it does:
/// the issuer is private and pinned, and the only other certificates it mints
/// for anyone else are CLIENT leaves (EKU `clientAuth`), which rustls refuses as
/// a server. WHICH primary, and which history, are proven one layer up by the
/// signed `LogHead` answer and the pinned `log_id` (lane T4) — not re-solved here.
pub const REPLICATION_SERVER_NAME: &str = "gunnar-replication";

/// **Where a box's mutual listener is, given its control plane's address**:
/// the same host, port + 1 — the default `gunnar serve` derives for any
/// control plane bound off loopback (`0.0.0.0:50051` ⇒ `50052`). One
/// definition, so the server that binds it and the operator that dials it
/// cannot disagree. A box started with an explicit `--grpc-replication-bind`
/// elsewhere is dialled at that address directly, not through this.
pub fn replication_address(control: &str) -> Result<String, String> {
    let rest = match control.split_once("://") {
        Some((_, rest)) => rest,
        None => control,
    };
    let host = host_of(control);
    let port: u16 = rest
        .rsplit_once(':')
        .and_then(|(_, p)| p.trim_end_matches('/').parse().ok())
        .ok_or_else(|| format!("{control:?} names no port, so there is no mutual listener beside it to derive"))?;
    let port = port
        .checked_add(1)
        .ok_or_else(|| format!("{control:?} is on the last port there is; nothing can sit at port + 1"))?;
    Ok(if host.contains(':') { format!("[{host}]:{port}") } else { format!("{host}:{port}") })
}

/// The name a server's certificate must carry to satisfy a dial of `at`.
///
/// Just the host, with the scheme and port stripped. It is its own function
/// because the caller must NOT pass the whole `host:port` — rustls compares the
/// SAN against a server name, and `10.77.0.1:50051` is in no certificate
/// anywhere. The listener mints its leaf with SANs `localhost`, `127.0.0.1` and
/// its bind IP (`gunnar-server`'s `GrpcTls::SelfMinted`), so an IP literal is
/// the normal shape here and is matched as an IP SAN.
pub fn tls_domain_for(at: &str) -> String {
    host_of(at).to_owned()
}

/// Is `at` this machine? Loopback literals and the name `localhost`, nothing else.
pub fn is_loopback_target(at: &str) -> bool {
    let host = host_of(at);
    if host.eq_ignore_ascii_case("localhost") {
        return true;
    }
    host.parse::<std::net::IpAddr>().map(|ip| ip.is_loopback()).unwrap_or(false)
}

/// The refusal [`plain_channel`] makes for a target that is not this box.
fn refuse_if_off_box(at: &str) -> Result<(), String> {
    if is_loopback_target(at) {
        return Ok(());
    }
    Err(format!(
        "{at:?} is not on this box, and a gunnar-to-gunnar call NEVER leaves a machine in the \
         clear. The transaction log this channel carries holds the estate's sealed secrets and \
         the KEK as seq 1, and the control-plane credential it is signed with is replayable by \
         anyone on the path. Dial it with `mutual_channel` and the client certificate, key and \
         issuer PEM this box was enrolled with; over WireGuard, as the estate requires. \
         `plain_channel` serves loopback only."
    ))
}

/// The three PEMs a box presents and pins when it calls **another** box.
///
/// One implementation, read from the environment, shared by every outbound
/// control-plane lane a server has: the twin's replication source and
/// `mirror.rs`'s `forget_once`. The CLI climbs the same ladder from the same
/// three variables (`gunnar-cli`'s `ClientTrust`), so an appliance's
/// `EnvironmentFile` configures all of them at once.
#[derive(Debug, Clone)]
pub struct ClientTrust {
    /// PEM of the CA that must have signed the **server's** certificate.
    pub ca: Vec<u8>,
    /// PEM of the certificate this box presents.
    pub cert: Vec<u8>,
    /// PEM of its private key.
    pub key: Vec<u8>,
}

/// The client certificate PEM (`GUNNAR_CLIENT_CERT`).
pub const CLIENT_CERT_ENV: &str = "GUNNAR_CLIENT_CERT";
/// Its private key PEM (`GUNNAR_CLIENT_KEY`).
pub const CLIENT_KEY_ENV: &str = "GUNNAR_CLIENT_KEY";
/// The issuer PEM this box pins (`GUNNAR_CA`).
pub const CA_ENV: &str = "GUNNAR_CA";

impl ClientTrust {
    /// Read the three files the three variables name.
    ///
    /// `Ok(None)` when **none** of the three is set. Two out of three is an
    /// error naming the missing one: a handshake failure with no hint about
    /// which file is absent is the shape this refusal exists to replace.
    pub fn from_env() -> Result<Option<ClientTrust>, String> {
        let read = |name: &str| std::env::var_os(name).filter(|value| !value.is_empty());
        let (cert, key, ca) = (read(CLIENT_CERT_ENV), read(CLIENT_KEY_ENV), read(CA_ENV));
        if cert.is_none() && key.is_none() && ca.is_none() {
            return Ok(None);
        }
        let want = |value: Option<std::ffi::OsString>, name: &str| {
            let path = value.ok_or_else(|| {
                format!(
                    "mutual TLS to another box needs all three of {CLIENT_CERT_ENV}, \
                     {CLIENT_KEY_ENV} and {CA_ENV}; {name} is not set. `gunnar control bootstrap` \
                     writes all three files."
                )
            })?;
            let path = std::path::PathBuf::from(path);
            std::fs::read(&path).map_err(|err| format!("reading {}: {err}", path.display()))
        };
        Ok(Some(ClientTrust {
            cert: want(cert, CLIENT_CERT_ENV)?,
            key: want(key, CLIENT_KEY_ENV)?,
            ca: want(ca, CA_ENV)?,
        }))
    }
}

/// A **mutually authenticated, TLS 1.3 only** channel to `at`, signed by
/// [`CredentialSigner`].
///
/// The second half of the owner's belt-and-braces ruling: WireGuard carries it,
/// and this proves who is at the other end of the tunnel with a certificate the
/// estate's own issuer signed. The CA is pinned — `with_webpki_roots` and
/// `with_native_roots` are deliberately not tonic features here — so a
/// perfectly valid certificate from a public CA is refused, which is the point.
///
/// `domain` is the SAN the server's leaf must carry. The listener mints itself
/// one for `localhost`, `127.0.0.1` and its bind IP
/// (`gunnar-server`'s `serve.rs`, `GrpcTls::SelfMinted`), so a caller dialling a
/// WireGuard address must pass the same address the primary bound — not the
/// tunnel name, which is in no certificate.
pub fn mutual_channel(at: &str, trust: &ClientTrust, domain: &str) -> Result<ControlChannel, String> {
    mutual_channel_with(at, trust, domain, |e| e)
}

/// [`mutual_channel`], its endpoint tuned by `tune` before it is dialled (the
/// twin's replication channel: HTTP/2 keepalive).
pub fn mutual_channel_with(at: &str, trust: &ClientTrust, domain: &str, tune: impl FnOnce(tonic::transport::Endpoint) -> tonic::transport::Endpoint) -> Result<ControlChannel, String> {
    // Before anything is dialled: pin the provider, then READ BACK that the
    // one that won is TLS 1.3 only. tonic has no version knob, so this is
    // where "no downgrade, no negotiation surface" is actually enforced.
    crate::ensure_crypto_provider();
    crate::refuse_unless_tls13_only().map_err(|err| err.to_string())?;

    let url = if at.contains("://") { at.to_owned() } else { format!("https://{at}") };
    if url.starts_with("http://") {
        return Err(format!(
            "{url} names plaintext http:// on a link that must be mutually authenticated. Drop \
             the scheme, or write https://."
        ));
    }
    let tls = tonic::transport::ClientTlsConfig::new()
        .domain_name(domain)
        .ca_certificate(tonic::transport::Certificate::from_pem(&trust.ca))
        .identity(tonic::transport::Identity::from_pem(&trust.cert, &trust.key));
    let endpoint = Channel::from_shared(url)
        .map_err(|e| format!("{at:?} is not an address the control plane can be reached at: {e}"))?
        .tls_config(tls)
        .map_err(|e| format!("configuring mutual TLS for the channel to {at:?}: {e}"))?;
    let channel = tune(endpoint).connect_lazy();
    Ok(tonic::service::interceptor::InterceptedService::new(channel, CredentialSigner))
}