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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/api/server_key.rs

//! **`GET /api/server-key` — the forge's SSH host key, as gunnar holds it.**
//!
//! Owner, 2026-10-02: the Overview's second line is "Server key SHA256:…", so a
//! user can check the fingerprint their ssh client shows on first connect
//! against the console before typing `yes`. It is the value the installer pins
//! in `forge-host-keys.toml` (gunnar.rs: `SHA256:e4I/Cns…`), and it is READ
//! LIVE, never written into the console: a re-imaged box mints a new key, and a
//! hard-coded fingerprint would then vouch for a key nobody serves.
//!
//! # Where gunnar says it
//!
//! `Mirror.LogHead`, asked WITH a nonce, answers `answer_key`: "the answering
//! box's public key, one OpenSSH `authorized_keys` line — its SSH host key, the
//! same one it proves itself with on :2222" (gunnar `mirror.proto` field 13).
//! A twin pins exactly this. No other call on gunnar's control plane carries
//! the host key, and this one needs no change to gunnar: the console's key is a
//! server reader there (`require_log_reader`, `Access::Server` Read). The nonce
//! is a fresh random one per ask; without it gunnar signs nothing and sends no
//! key. The call has no side effect: a heartbeat is recorded only for a
//! `report`, which this never sends.
//!
//! The fingerprint is computed HERE from the key gunnar sent, with `ssh-key`'s
//! own SHA-256 fingerprint — the `SHA256:<base64, no padding>` ssh prints —
//! never taken as a string from anybody.

use axum::extract::State;
use axum::Json;
use serde::Serialize;

use crate::api::{ApiError, ApiResult};
use crate::app::AppState;
use crate::grpc::Fail;
use crate::pb;

/// The forge's SSH host key.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ServerKey {
    /// `SHA256:…`, as `ssh` and `ssh-keygen -lf` print it.
    pub fingerprint: String,
    /// The public key, one OpenSSH line (`ssh-ed25519 AAAA…`), comment dropped.
    pub public_key: String,
}

fn refuse(code: &str, message: impl Into<String>) -> ApiError {
    ApiError(Fail { code: code.to_owned(), message: message.into() })
}

/// A fresh nonce: 16 random bytes, hex.
fn nonce() -> String {
    let mut b = [0u8; 16];
    rand_bytes(&mut b);
    b.iter().map(|x| format!("{x:02x}")).collect()
}

fn rand_bytes(out: &mut [u8]) {
    use rand::Rng as _;
    rand::rand_core::UnwrapErr(rand::rngs::SysRng).fill_bytes(out);
}

/// The key line gunnar sent → what the page shows. `None` for anything that is
/// not one OpenSSH public key.
pub fn server_key_of(line: &str) -> Option<ServerKey> {
    let key = ssh_key::PublicKey::from_openssh(line.trim()).ok()?;
    let mut bare = key.clone();
    bare.set_comment("");
    Some(ServerKey {
        fingerprint: key.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
        public_key: bare.to_openssh().ok()?.trim().to_owned(),
    })
}

/// `GET /api/server-key`
pub async fn server_key(State(state): State<AppState>) -> ApiResult<ServerKey> {
    let head = state
        .gunnar
        .mirror()
        .log_head(pb::LogHeadRequest { nonce: nonce(), ..Default::default() })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    server_key_of(&head.answer_key).map(Json).ok_or_else(|| {
        refuse(
            "unavailable",
            "Gunnar did not name its SSH host key (Mirror.LogHead sent no answer_key): the server key cannot be shown",
        )
    })
}

#[cfg(test)]
mod tests {
    use std::sync::{Arc, Mutex};

    use tonic::{Request, Response, Status};
    use tower::ServiceExt as _;

    use super::*;
    use crate::grpc::Gunnar;

    fn host_key() -> ssh_key::PrivateKey {
        ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[42u8; 32]))
    }

    /// gunnar's `log_head_signed`: the key only for a caller that sent a nonce.
    #[derive(Clone, Default)]
    struct FakeForge {
        nonces: Arc<Mutex<Vec<String>>>,
        silent: bool,
    }

    type Chunks = std::pin::Pin<Box<dyn tokio_stream::Stream<Item = Result<pb::LogChunk, Status>> + Send + 'static>>;

    #[tonic::async_trait]
    impl pb::mirror_server::Mirror for FakeForge {
        async fn log_head(&self, r: Request<pb::LogHeadRequest>) -> Result<Response<pb::LogHeadResponse>, Status> {
            let r = r.into_inner();
            assert!(r.report.is_none(), "the console sent a heartbeat report: it would be recorded as a twin");
            self.nonces.lock().unwrap().push(r.nonce.clone());
            let mut head = pb::LogHeadResponse { head_seq: 7, first_seq: 1, ..Default::default() };
            if !r.nonce.trim().is_empty() && !self.silent {
                head.answer_key = host_key().public_key().to_openssh().unwrap();
                head.answer_sig = "sig".into();
            }
            Ok(Response::new(head))
        }
        async fn log_fetch(&self, _: Request<pb::LogFetchRequest>) -> Result<Response<Chunks>, Status> {
            Err(Status::unimplemented("not here"))
        }
        async fn snapshot_fetch(&self, _: Request<pb::SnapshotFetchRequest>) -> Result<Response<Chunks>, Status> {
            Err(Status::unimplemented("not here"))
        }
    }

    async fn serve(f: FakeForge) -> String {
        let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let at = format!("http://{}", l.local_addr().unwrap());
        tokio::spawn(tonic::transport::Server::builder().add_service(pb::mirror_server::MirrorServer::new(f)).serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(l)));
        at
    }

    fn router(at: &str) -> axum::Router {
        let signer = crate::signer::Signer::new(ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[9u8; 32]))).unwrap();
        let gunnar = Gunnar::connect_lazy(at, Arc::new(signer)).unwrap();
        let store = crate::state::StateStore::open(tempfile::Builder::new().suffix(".redb").tempfile().unwrap().into_temp_path().keep().unwrap()).unwrap();
        crate::serve::router(Arc::new(crate::app::App {
            webauthn: None,
            #[cfg(feature = "metered")]
            monetize: None,
            admin_token: None,
            events: crate::sse::EventHub::detached(),
            gunnar,
            twin: None,
            guest: None,
            reviewers: Arc::new(crate::reviewer::ReviewerKeys::new(at.to_owned(), store.clone())),
            tenants: Arc::new(crate::tenant::TenantKeys::new(at.to_owned(), store)),
            forges: Default::default(),
            forge_apps: Default::default(),
            ui_dir: None,
            caches: crate::api::Caches::default(),
            objects: Default::default(),
            readmes: Default::default(),
            imports: Default::default(),
        }))
    }

    async fn get(app: &axum::Router) -> (http::StatusCode, serde_json::Value) {
        let request = http::Request::builder().uri("/api/server-key").body(axum::body::Body::empty()).unwrap();
        let response = app.clone().oneshot(request).await.unwrap();
        let status = response.status();
        let bytes = axum::body::to_bytes(response.into_body(), 1 << 20).await.unwrap();
        (status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
    }

    /// ★ **The Overview's server key is the forge's own SSH host key, read
    /// live from gunnar**: the fingerprint ssh prints for it, computed here from
    /// the key gunnar sent, asked with a fresh nonce each time.
    #[tokio::test]
    async fn the_server_key_is_the_forges_ssh_host_key_read_live_from_gunnar() {
        let forge = FakeForge::default();
        let app = router(&serve(forge.clone()).await);
        let (status, json) = get(&app).await;
        assert_eq!(status, http::StatusCode::OK, "{json}");
        let want = host_key().public_key().fingerprint(ssh_key::HashAlg::Sha256).to_string();
        assert!(want.starts_with("SHA256:"), "{want}");
        assert_eq!(json["fingerprint"], want.as_str(), "{json}");
        assert!(json["public_key"].as_str().unwrap().starts_with("ssh-ed25519 AAAA"), "{json}");
        let (_, again) = get(&app).await;
        assert_eq!(again["fingerprint"], want.as_str());
        let nonces = forge.nonces.lock().unwrap().clone();
        assert_eq!(nonces.len(), 2);
        assert!(nonces.iter().all(|n| n.len() == 32), "a 16-byte nonce per ask: {nonces:?}");
        assert_ne!(nonces[0], nonces[1], "the nonce is fresh per ask");
    }

    /// A gunnar that names no key: a refusal that says so, never a made-up line.
    #[tokio::test]
    async fn a_gunnar_that_names_no_key_is_a_refusal_not_a_blank_fingerprint() {
        let app = router(&serve(FakeForge { silent: true, ..FakeForge::default() }).await);
        let (status, json) = get(&app).await;
        assert_ne!(status, http::StatusCode::OK, "{json}");
        assert!(json.to_string().contains("answer_key"), "{json}");
        assert_eq!(server_key_of("not a key"), None);
        assert_eq!(server_key_of(""), None);
    }
}