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

nordisk/gunnar

crates/gunnar-pairing-door/src/grpc.rs

//! **[`Boxes`] over the boxes' real control planes** — the generated clients,
//! the one credential signer (`gunnar_grpc::client_signer`) and the membership
//! signer (`gunnar_core::membership`), the same calls `gunnar control user
//! add`, `group add`, `group member add` and `twin certify` make.
//!
//! The planes are dialled at `<utility>:50051` in plain HTTP/2, as the front's
//! CLI dialled them for the xtask: every call that changes anything carries a
//! fresh sshsig credential from the console key — the twin-making Bootstrap
//! calls too (`BecomeTwin`, `InstallTwinCertificate`, h-bootstrap-open
//! 2026-09-28: the twin takes them only from a key it was fed, and the console
//! key is one, `offer.<front>.console_pubkey`).
//! `client_signer::plain_channel` is NOT used: it refuses any off-box target
//! by design, and this is the one caller that has to cross the utility network.

use crate::certify::{Boxes, Issued};
use gunnar_core::ssh_key;
use gunnar_grpc::client_signer::{authed, ControlChannel, CredentialSigner};
use gunnar_proto::access_client::AccessClient;
use gunnar_proto::admin_client::AdminClient;
use gunnar_proto::bootstrap_client::BootstrapClient;
use std::time::Duration;
use tonic::transport::Channel;
use tonic::{Code, Status};

pub struct GrpcBoxes {
    key: ssh_key::PrivateKey,
    rt: tokio::runtime::Runtime,
    timeout: Duration,
}

impl GrpcBoxes {
    pub fn new(key: ssh_key::PrivateKey, timeout: Duration) -> Result<GrpcBoxes, String> {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .map_err(|e| format!("a runtime for the certify calls: {e}"))?;
        Ok(GrpcBoxes { key, rt, timeout })
    }

    fn channel(&self, at: &str) -> Result<ControlChannel, String> {
        let _g = self.rt.enter();
        let endpoint = Channel::from_shared(format!("http://{at}"))
            .map_err(|e| format!("{at:?} is not a control-plane address: {e}"))?
            .connect_timeout(self.timeout)
            .timeout(self.timeout);
        Ok(tonic::service::interceptor::InterceptedService::new(
            endpoint.connect_lazy(),
            CredentialSigner,
        ))
    }
}

/// A refusal in words that keep "could not be reached" apart from "refused".
pub fn say(status: &Status) -> String {
    match status.code() {
        Code::Unavailable | Code::DeadlineExceeded => format!(
            "could not be reached ({:?}): {}",
            status.code(),
            status.message()
        ),
        code => format!("{code:?}: {}", status.message()),
    }
}

/// An idempotent enrolment step whose thing is already there.
pub fn already(status: &Status) -> bool {
    status.code() == Code::AlreadyExists || {
        let m = status.message().to_ascii_lowercase();
        m.contains("already exists")
            || m.contains("already a member")
            || m.contains("already registered")
    }
}

fn tolerate<T>(r: Result<T, Status>, what: &str) -> Result<(), String> {
    match r {
        Ok(_) => Ok(()),
        Err(s) if already(&s) => Ok(()),
        Err(s) => Err(format!("{what}: {}", say(&s))),
    }
}

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}

impl Boxes for GrpcBoxes {
    fn twin_key(&self, twin_ctl: &str, primary_for_twin: &str) -> Result<String, String> {
        let mut twin = BootstrapClient::new(self.channel(twin_ctl)?);
        let r = self
            .rt
            .block_on(twin.become_twin(authed(
                &self.key,
                gunnar_proto::BecomeTwinRequest {
                    primary: primary_for_twin.to_string(),
                },
            )))
            .map_err(|s| say(&s))?;
        Ok(r.into_inner().twin_public_key)
    }

    fn enrol(&self, primary_ctl: &str, principal: &str, key: &str) -> Result<(), String> {
        let mut access = AccessClient::new(self.channel(primary_ctl)?);
        let rt = &self.rt;
        tolerate(
            rt.block_on(access.add_principal(authed(
                &self.key,
                gunnar_proto::AddPrincipalRequest {
                    name: principal.into(),
                    kind: gunnar_proto::PrincipalKind::Machine as i32,
                    id: principal.into(),
                },
            ))),
            "the principal",
        )?;
        // **The twin's key REPLACES the principal's other ssh keys** (twin reborn,
        // 2026-09-30). ONE twin per principal — [`crate::certify::TWIN_PRINCIPAL`]
        // is the one name the owner chose for now — so a NEW key here is a new
        // twin box, and the one it replaces is dead (nuked: its disk may survive
        // as an image or a backup). Its key is revoked FIRST: left registered it
        // still signs `Mirror.LogFetch` on the plain control plane, and while it is
        // there `Access.AddKey` refuses the new key without an attestation by the
        // old one — which died with the box, so the door retried for ever. The
        // same key again (a re-run for the same twin) revokes nothing and adds
        // nothing new. Certificates are `Admin.IssueTwinCertificate`'s to replace.
        let fingerprint = gunnar_core::Fingerprint::of_openssh(key.trim())
            .map_err(|e| format!("the twin's key {key:?}: {e}"))?;
        let held = rt
            .block_on(access.list_keys(authed(
                &self.key,
                gunnar_proto::ListKeysRequest {
                    principal_id: principal.into(),
                },
            )))
            .map_err(|s| format!("the keys {principal} holds: {}", say(&s)))?
            .into_inner()
            .keys;
        for old in held.iter().filter(|k| {
            k.principal_id == principal && k.algo != "x509" && k.fingerprint != fingerprint.as_str()
        }) {
            rt.block_on(access.revoke_key(authed(
                &self.key,
                gunnar_proto::RevokeKeyRequest {
                    fingerprint: old.fingerprint.clone(),
                },
            )))
            .map_err(|s| {
                format!(
                    "revoking {principal}'s previous key {}: {}",
                    old.fingerprint,
                    say(&s)
                )
            })?;
        }
        tolerate(
            rt.block_on(access.add_key(authed(
                &self.key,
                gunnar_proto::AddKeyRequest {
                    principal_id: principal.into(),
                    openssh_public_key: key.into(),
                    comment: "the twin's key, enrolled by the pairing door".into(),
                    expires_at_unix_ms: None,
                    attestation: None,
                },
            ))),
            "the twin's key",
        )?;
        tolerate(
            rt.block_on(access.add_principal(authed(
                &self.key,
                gunnar_proto::AddPrincipalRequest {
                    name: gunnar_grpc::TWIN_GROUP.into(),
                    kind: gunnar_proto::PrincipalKind::Group as i32,
                    id: String::new(),
                },
            ))),
            "the group",
        )?;
        // The membership change is signed into the membership log by the
        // acting admin — the console key's own principal, looked up by it.
        let fingerprint = self
            .key
            .public_key()
            .fingerprint(ssh_key::HashAlg::Sha256)
            .to_string();
        let keys = rt
            .block_on(access.list_keys(authed(
                &self.key,
                gunnar_proto::ListKeysRequest {
                    principal_id: String::new(),
                },
            )))
            .map_err(|s| format!("the console key's principal: {}", say(&s)))?
            .into_inner()
            .keys;
        let actor = keys
            .into_iter()
            .find(|k| k.fingerprint == fingerprint)
            .map(|k| k.principal_id)
            .ok_or_else(|| {
                format!("the console key {fingerprint} is not registered on the primary")
            })?;
        let event = gunnar_core::MembershipEvent {
            group: gunnar_grpc::TWIN_GROUP.into(),
            member: principal.into(),
            action: gunnar_core::MembershipAction::Add,
            actor,
            at_unix_ms: now_ms(),
        };
        let signed = gunnar_core::membership::sign_event(&self.key, event)
            .map_err(|e| format!("signing the membership: {e}"))?;
        let signed = gunnar_proto::SignedMembershipEvent {
            event: Some(gunnar_proto::MembershipEvent {
                group: signed.event.group,
                member: signed.event.member,
                action: gunnar_proto::MembershipAction::Add as i32,
                actor: signed.event.actor,
                at_unix_ms: signed.event.at_unix_ms,
            }),
            key_fingerprint: signed.key_fingerprint,
            signature: signed.signature,
        };
        tolerate(
            rt.block_on(access.add_group_member(authed(
                &self.key,
                gunnar_proto::AddGroupMemberRequest {
                    group_id: gunnar_grpc::TWIN_GROUP.into(),
                    member_id: principal.into(),
                    signed: Some(signed),
                },
            ))),
            "the membership",
        )
    }

    fn twin_csr(&self, twin_ctl: &str) -> Result<String, String> {
        let mut twin = BootstrapClient::new(self.channel(twin_ctl)?);
        Ok(self
            .rt
            .block_on(twin.twin_csr(gunnar_proto::TwinCsrRequest {}))
            .map_err(|s| say(&s))?
            .into_inner()
            .csr_pem)
    }

    fn issue(&self, primary_ctl: &str, principal: &str, csr_pem: &str) -> Result<Issued, String> {
        let mut admin = AdminClient::new(self.channel(primary_ctl)?);
        let r = self
            .rt
            .block_on(admin.issue_twin_certificate(authed(
                &self.key,
                gunnar_proto::IssueTwinCertificateRequest {
                    principal: principal.into(),
                    csr_pem: csr_pem.into(),
                },
            )))
            .map_err(|s| say(&s))?
            .into_inner();
        Ok(Issued {
            cert_pem: r.cert_pem,
            issuer_pem: r.issuer_pem,
            port: r.replication_port,
            host: r.replication_host,
        })
    }

    fn install(&self, twin_ctl: &str, issued: &Issued) -> Result<i64, String> {
        let mut twin = BootstrapClient::new(self.channel(twin_ctl)?);
        let r = self
            .rt
            .block_on(twin.install_twin_certificate(authed(
                &self.key,
                gunnar_proto::InstallTwinCertificateRequest {
                    cert_pem: issued.cert_pem.clone(),
                    ca_pem: issued.issuer_pem.clone(),
                    replication_port: issued.port,
                    replication_host: issued.host.clone(),
                },
            )))
            .map_err(|s| say(&s))?;
        Ok(r.into_inner().days_left)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn already_there_is_fine_and_unreachable_is_said_apart_from_refused() {
        assert!(already(&Status::already_exists("principal gunnar-twin")));
        assert!(already(&Status::failed_precondition(
            "gunnar-twin is already a member of twin"
        )));
        assert!(!already(&Status::permission_denied("not an admin")));
        assert!(say(&Status::unavailable("tcp connect error")).starts_with("could not be reached"));
        assert!(say(&Status::unauthenticated("no key")).starts_with("Unauthenticated:"));
    }

    /// A real dial at a port nobody listens on: the door's error names it
    /// unreachable, within the timeout, and never hangs the thread.
    #[test]
    fn a_box_that_is_not_there_is_unreachable_not_refused() {
        let key =
            ssh_key::PrivateKey::random(&mut gunnar_core::os_rng(), ssh_key::Algorithm::Ed25519)
                .unwrap();
        let boxes = GrpcBoxes::new(key, Duration::from_secs(2)).unwrap();
        let port = std::net::TcpListener::bind("127.0.0.1:0")
            .unwrap()
            .local_addr()
            .unwrap()
            .port();
        let e = boxes
            .twin_key(&format!("127.0.0.1:{port}"), "10.44.0.2:50051")
            .unwrap_err();
        assert!(e.starts_with("could not be reached"), "{e}");
        let e = boxes
            .enrol(
                &format!("127.0.0.1:{port}"),
                "gunnar-twin",
                "ssh-ed25519 AAAA",
            )
            .unwrap_err();
        assert!(e.contains("could not be reached"), "{e}");
    }
}